Merge branch 'main' into main
commit
e1d3a7a05c
|
|
@ -20,9 +20,11 @@
|
|||
# "!denounce" or "!denounce [username]" on a discussion.
|
||||
00-kat
|
||||
007hacky007
|
||||
00jciv00
|
||||
04cb
|
||||
0xdvc
|
||||
-4rh1t3ct0r7
|
||||
52dyd
|
||||
aalhendi
|
||||
aaron-ang
|
||||
abdurrahmanski
|
||||
|
|
@ -43,7 +45,10 @@ andrejdaskalov
|
|||
anhthang
|
||||
anmitalidev
|
||||
anthonyzhoon
|
||||
athaapa
|
||||
atomk
|
||||
b0uks
|
||||
b1nar10
|
||||
balazs-szucs
|
||||
barutsrb
|
||||
bch
|
||||
|
|
@ -57,6 +62,7 @@ bleikurr
|
|||
bo2themax
|
||||
brentschroeter
|
||||
brianc442
|
||||
c0x0o
|
||||
cespare
|
||||
charliie-dev
|
||||
chernetskyi
|
||||
|
|
@ -65,6 +71,7 @@ cmwetherell
|
|||
crayxt
|
||||
craziestowl
|
||||
curtismoncoq
|
||||
-cznorth Automated advertising + likely AI communication
|
||||
d-dudas
|
||||
-daedaevibin
|
||||
daiimus
|
||||
|
|
@ -91,8 +98,10 @@ elias8
|
|||
-enkr1
|
||||
enzowilliam
|
||||
ephemera
|
||||
-eric-assetpass Try talking, not botting
|
||||
eriksremess
|
||||
erral
|
||||
-f1813483-netizen
|
||||
faukah
|
||||
filip7
|
||||
flou
|
||||
|
|
@ -151,6 +160,7 @@ kristofersoler
|
|||
kylesower
|
||||
laxystem
|
||||
lebdron
|
||||
lepips
|
||||
liby
|
||||
linustalacko
|
||||
lonsagisawa
|
||||
|
|
@ -164,6 +174,7 @@ markdorison
|
|||
markhuot
|
||||
marler8997
|
||||
marrocco-simone
|
||||
masterflitzer
|
||||
matkotiric
|
||||
mattn
|
||||
micaeljarniac
|
||||
|
|
@ -171,10 +182,13 @@ michielvk
|
|||
miguelelgallo
|
||||
mihi314
|
||||
mikailmm
|
||||
minorcell
|
||||
misairuzame
|
||||
mischief
|
||||
mitchellh
|
||||
miupa
|
||||
mjbommar
|
||||
mohshami
|
||||
molechowski
|
||||
moonmao42
|
||||
-morgengeluk Appears to be using AI inappropriately even after it was requested they abide by the AI policy (there is clear evidence of the person-in-the-loop not attempting to clean up AI generated text, and their AI disclosure itself reads like AI-generated text and shows no signs of remorse or intent to improve).
|
||||
|
|
@ -187,8 +201,10 @@ neo773
|
|||
neurosnap
|
||||
nicholas-ochoa
|
||||
nicosuave
|
||||
nikicat
|
||||
nmggithub
|
||||
noib3
|
||||
nolinmcfarland
|
||||
nouritsu
|
||||
nwehg
|
||||
ocean6954
|
||||
|
|
@ -210,7 +226,9 @@ prakhar54-byte
|
|||
priyans-hu
|
||||
puzza007
|
||||
qwerasd205
|
||||
raphamorim
|
||||
reo101
|
||||
rewdy
|
||||
rgehan
|
||||
rhodes-b
|
||||
rightaditya
|
||||
|
|
@ -230,6 +248,7 @@ sunshine-syz
|
|||
tbrundige
|
||||
tdgroot
|
||||
tdslot
|
||||
thirstycrow
|
||||
thoutbeckers
|
||||
ticclick
|
||||
tnagatomi
|
||||
|
|
@ -240,6 +259,7 @@ tweedbeetle
|
|||
uhojin
|
||||
unphased
|
||||
uzaaft
|
||||
vancluever
|
||||
vaughanandrews
|
||||
viruslobster
|
||||
vlsi
|
||||
|
|
@ -248,6 +268,7 @@ wyounas
|
|||
yabbal
|
||||
yamshta
|
||||
ydah
|
||||
-zaviro
|
||||
zenyr
|
||||
zeshi09
|
||||
zubb
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -147,7 +147,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -178,7 +178,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -233,7 +233,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -312,7 +312,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -404,7 +404,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -660,7 +660,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -857,7 +857,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -227,7 +227,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -263,7 +263,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -329,7 +329,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -369,7 +369,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -407,7 +407,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -524,7 +524,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -558,7 +558,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -603,7 +603,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -639,7 +639,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -679,7 +679,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -756,7 +756,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -785,7 +785,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -818,7 +818,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -900,7 +900,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -940,7 +940,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1008,7 +1008,7 @@ jobs:
|
|||
# - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
# with:
|
||||
# nix_path: nixpkgs=channel:nixos-unstable
|
||||
# - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
# - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
# with:
|
||||
# name: ghostty
|
||||
# authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1071,7 +1071,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1131,7 +1131,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1185,7 +1185,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1222,7 +1222,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1257,7 +1257,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1305,7 +1305,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1340,7 +1340,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1371,7 +1371,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1430,7 +1430,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1461,7 +1461,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1503,7 +1503,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1534,7 +1534,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1564,7 +1564,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1593,7 +1593,7 @@ jobs:
|
|||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1622,7 +1622,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1650,7 +1650,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1678,7 +1678,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1711,7 +1711,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1739,7 +1739,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1776,7 +1776,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
@ -1808,7 +1808,7 @@ jobs:
|
|||
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: dist
|
||||
file: dist/src/build/docker/debian/Dockerfile
|
||||
|
|
@ -1838,7 +1838,7 @@ jobs:
|
|||
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ jobs:
|
|||
check:
|
||||
runs-on: namespace-profile-ghostty-xsm
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.VOUCH_APP_ID }}
|
||||
|
|
@ -18,10 +18,11 @@ jobs:
|
|||
with:
|
||||
sparse-checkout: .github/issue-unvouched-message
|
||||
|
||||
- uses: mitchellh/vouch/action/check-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
|
||||
- uses: mitchellh/vouch/action/check-issue@52aec3d64655edf2fdb58f298e02da754a056daf # unreleased main
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
auto-close: true
|
||||
auto-lock: true
|
||||
template-file: .github/issue-unvouched-message
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ jobs:
|
|||
check:
|
||||
runs-on: namespace-profile-ghostty-xsm
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.VOUCH_APP_ID }}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
manage:
|
||||
runs-on: namespace-profile-ghostty-xsm
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.VOUCH_APP_ID }}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
manage:
|
||||
runs-on: namespace-profile-ghostty-xsm
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.VOUCH_APP_ID }}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
sync:
|
||||
runs-on: namespace-profile-ghostty-xsm
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.VOUCH_APP_ID }}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
/po/es_AR.po @ghostty-org/es_AR
|
||||
/po/es_BO.po @ghostty-org/es_BO
|
||||
/po/es_ES.po @ghostty-org/es_ES
|
||||
/po/eu.po @ghostty-org/eu_ES
|
||||
/po/fr.po @ghostty-org/fr_FR
|
||||
/po/ga.po @ghostty-org/ga_IE
|
||||
/po/he.po @ghostty-org/he_IL
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@
|
|||
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
||||
.android_ndk = .{ .path = "./pkg/android-ndk" },
|
||||
.iterm2_themes = .{
|
||||
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz",
|
||||
.hash = "N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ",
|
||||
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz",
|
||||
.hash = "N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I",
|
||||
.lazy = true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@
|
|||
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
|
||||
},
|
||||
"N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ": {
|
||||
"N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I": {
|
||||
"name": "iterm2_themes",
|
||||
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz",
|
||||
"hash": "sha256-3iY7YiCQrhLGcH1nVNozirX1DW9/WyRNaJCElJzcKwU="
|
||||
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz",
|
||||
"hash": "sha256-HGujRdQdWtVIf3GwCgQgjV9lbwWxJSIDJOWq3gOX3kU="
|
||||
},
|
||||
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
|
||||
"name": "jetbrains_mono",
|
||||
|
|
|
|||
|
|
@ -193,11 +193,11 @@ in
|
|||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ";
|
||||
name = "N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I";
|
||||
path = fetchZigArtifact {
|
||||
name = "iterm2_themes";
|
||||
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz";
|
||||
hash = "sha256-3iY7YiCQrhLGcH1nVNozirX1DW9/WyRNaJCElJzcKwU=";
|
||||
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz";
|
||||
hash = "sha256-HGujRdQdWtVIf3GwCgQgjV9lbwWxJSIDJOWq3gOX3kU=";
|
||||
unpack = false;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
|
|||
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
|
||||
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
|
||||
https://deps.files.ghostty.org/gettext-0.24.tar.gz
|
||||
https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz
|
||||
https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz
|
||||
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
|
||||
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
|
||||
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
# Example: `ghostty-vt` Tracked Grid References
|
||||
|
||||
This contains a simple example of how to use the `ghostty-vt` terminal and
|
||||
tracked grid reference APIs to keep a long-lived reference to a cell as the
|
||||
terminal scrolls, detect when that reference loses its meaningful location,
|
||||
and move the same tracked handle to a new point.
|
||||
|
||||
This uses a `build.zig` and `Zig` to build the C program so that we
|
||||
can reuse a lot of our build logic and depend directly on our source
|
||||
tree, but Ghostty emits a standard C library that can be used with any
|
||||
C tooling.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the program:
|
||||
|
||||
```shell-session
|
||||
zig build run
|
||||
```
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const run_step = b.step("run", "Run the app");
|
||||
|
||||
const exe_mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
exe_mod.addCSourceFiles(.{
|
||||
.root = b.path("src"),
|
||||
.files = &.{"main.c"},
|
||||
});
|
||||
|
||||
// You'll want to use a lazy dependency here so that ghostty is only
|
||||
// downloaded if you actually need it.
|
||||
if (b.lazyDependency("ghostty", .{
|
||||
// Setting simd to false will force a pure static build that
|
||||
// doesn't even require libc, but it has a significant performance
|
||||
// penalty. If your embedding app requires libc anyway, you should
|
||||
// always keep simd enabled.
|
||||
// .simd = false,
|
||||
})) |dep| {
|
||||
exe_mod.linkLibrary(dep.artifact("ghostty-vt"));
|
||||
}
|
||||
|
||||
// Exe
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "c_vt_grid_ref_tracked",
|
||||
.root_module = exe_mod,
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
// Run
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| run_cmd.addArgs(args);
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
.{
|
||||
.name = .c_vt_grid_ref_tracked,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0x64bd14b59e76c294,
|
||||
.minimum_zig_version = "0.15.1",
|
||||
.dependencies = .{
|
||||
// Ghostty dependency. In reality, you'd probably use a URL-based
|
||||
// dependency like the one showed (and commented out) below this one.
|
||||
// We use a path dependency here for simplicity and to ensure our
|
||||
// examples always test against the source they're bundled with.
|
||||
.ghostty = .{ .path = "../../" },
|
||||
|
||||
// Example of what a URL-based dependency looks like:
|
||||
// .ghostty = .{
|
||||
// .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz",
|
||||
// .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s",
|
||||
// },
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
//! [grid-ref-tracked]
|
||||
static uint32_t codepoint_at_tracked_ref(GhosttyTrackedGridRef tracked) {
|
||||
GhosttyGridRef snapshot = GHOSTTY_INIT_SIZED(GhosttyGridRef);
|
||||
GhosttyResult result = ghostty_tracked_grid_ref_snapshot(tracked, &snapshot);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttyCell cell;
|
||||
result = ghostty_grid_ref_cell(&snapshot, &cell);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
bool has_text = false;
|
||||
ghostty_cell_get(cell, GHOSTTY_CELL_DATA_HAS_TEXT, &has_text);
|
||||
assert(has_text);
|
||||
|
||||
uint32_t codepoint = 0;
|
||||
ghostty_cell_get(cell, GHOSTTY_CELL_DATA_CODEPOINT, &codepoint);
|
||||
return codepoint;
|
||||
}
|
||||
|
||||
int main() {
|
||||
GhosttyTerminal terminal;
|
||||
GhosttyTerminalOptions opts = {
|
||||
.cols = 8,
|
||||
.rows = 3,
|
||||
.max_scrollback = 100,
|
||||
};
|
||||
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
const char *text = "alpha\r\n"
|
||||
"bravo\r\n"
|
||||
"charlie";
|
||||
ghostty_terminal_vt_write(
|
||||
terminal, (const uint8_t *)text, strlen(text));
|
||||
|
||||
GhosttyTrackedGridRef tracked = NULL;
|
||||
GhosttyPoint alpha = {
|
||||
.tag = GHOSTTY_POINT_TAG_ACTIVE,
|
||||
.value = { .coordinate = { .x = 0, .y = 0 } },
|
||||
};
|
||||
result = ghostty_terminal_grid_ref_track(terminal, alpha, &tracked);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
// Writing another line scrolls the original "alpha" row into scrollback.
|
||||
// The tracked ref still follows the same cell.
|
||||
const char *more = "\r\ndelta";
|
||||
ghostty_terminal_vt_write(
|
||||
terminal, (const uint8_t *)more, strlen(more));
|
||||
|
||||
assert(ghostty_tracked_grid_ref_has_value(tracked));
|
||||
printf("tracked codepoint after scroll: %c\n",
|
||||
(char)codepoint_at_tracked_ref(tracked));
|
||||
|
||||
GhosttyPointCoordinate screen = {0};
|
||||
result = ghostty_tracked_grid_ref_point(
|
||||
tracked, GHOSTTY_POINT_TAG_SCREEN, &screen);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
printf("tracked screen point: %u,%u\n", screen.x, screen.y);
|
||||
|
||||
// Resetting the terminal discards the old grid contents. The tracked
|
||||
// handle remains valid, but no longer has a meaningful location.
|
||||
ghostty_terminal_reset(terminal);
|
||||
assert(!ghostty_tracked_grid_ref_has_value(tracked));
|
||||
|
||||
GhosttyGridRef discarded = GHOSTTY_INIT_SIZED(GhosttyGridRef);
|
||||
result = ghostty_tracked_grid_ref_snapshot(tracked, &discarded);
|
||||
assert(result == GHOSTTY_NO_VALUE);
|
||||
|
||||
// The same handle can be moved to a new point after it loses its value.
|
||||
const char *replacement = "echo";
|
||||
ghostty_terminal_vt_write(
|
||||
terminal, (const uint8_t *)replacement, strlen(replacement));
|
||||
|
||||
GhosttyPoint echo = {
|
||||
.tag = GHOSTTY_POINT_TAG_ACTIVE,
|
||||
.value = { .coordinate = { .x = 0, .y = 0 } },
|
||||
};
|
||||
result = ghostty_tracked_grid_ref_set(tracked, terminal, echo);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
assert(ghostty_tracked_grid_ref_has_value(tracked));
|
||||
printf("tracked codepoint after reset/set: %c\n",
|
||||
(char)codepoint_at_tracked_ref(tracked));
|
||||
|
||||
ghostty_tracked_grid_ref_free(tracked);
|
||||
ghostty_terminal_free(terminal);
|
||||
return 0;
|
||||
}
|
||||
//! [grid-ref-tracked]
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
This contains an example of how to use the `ghostty-vt` render-state API
|
||||
to create a render state, update it from terminal content, iterate rows
|
||||
and cells, read styles and colors, inspect cursor state, and manage dirty
|
||||
tracking.
|
||||
and cells, read styles and colors, inspect cursor and row-local selection
|
||||
state, and manage dirty tracking.
|
||||
|
||||
This uses a `build.zig` and `Zig` to build the C program so that we
|
||||
can reuse a lot of our build logic and depend directly on our source
|
||||
|
|
|
|||
|
|
@ -46,6 +46,32 @@ int main(void) {
|
|||
ghostty_terminal_vt_write(
|
||||
terminal, (const uint8_t*)content, strlen(content));
|
||||
|
||||
// Select "underlined" on the second row. Render state exposes this
|
||||
// later as a row-local selected cell range.
|
||||
GhosttyGridRef selection_start = GHOSTTY_INIT_SIZED(GhosttyGridRef);
|
||||
GhosttyPoint selection_start_pt = {
|
||||
.tag = GHOSTTY_POINT_TAG_ACTIVE,
|
||||
.value = { .coordinate = { .x = 0, .y = 1 } },
|
||||
};
|
||||
result = ghostty_terminal_grid_ref(
|
||||
terminal, selection_start_pt, &selection_start);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttyGridRef selection_end = GHOSTTY_INIT_SIZED(GhosttyGridRef);
|
||||
GhosttyPoint selection_end_pt = {
|
||||
.tag = GHOSTTY_POINT_TAG_ACTIVE,
|
||||
.value = { .coordinate = { .x = 9, .y = 1 } },
|
||||
};
|
||||
result = ghostty_terminal_grid_ref(terminal, selection_end_pt, &selection_end);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
|
||||
selection.start = selection_start;
|
||||
selection.end = selection_end;
|
||||
result = ghostty_terminal_set(
|
||||
terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
result = ghostty_render_state_update(render_state, terminal);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
//! [render-state-update]
|
||||
|
|
@ -154,6 +180,18 @@ int main(void) {
|
|||
printf("Row %2d [%s]: ", row_index,
|
||||
row_dirty ? "dirty" : "clean");
|
||||
|
||||
// Query the row-local selection range. Rows without a selection return
|
||||
// GHOSTTY_NO_VALUE; selected rows return inclusive start/end columns.
|
||||
GhosttyRenderStateRowSelection row_selection =
|
||||
GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection);
|
||||
result = ghostty_render_state_row_get(
|
||||
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION, &row_selection);
|
||||
assert(result == GHOSTTY_SUCCESS || result == GHOSTTY_NO_VALUE);
|
||||
if (result == GHOSTTY_SUCCESS) {
|
||||
printf("selection=%u..%u ",
|
||||
row_selection.start_x, row_selection.end_x);
|
||||
}
|
||||
|
||||
// Get cells for this row (reuses the same cells handle).
|
||||
result = ghostty_render_state_row_get(
|
||||
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, &cells);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Example: `ghostty-vt` Selection Gestures
|
||||
|
||||
This contains a simple example of how to use the `ghostty-vt` selection
|
||||
gesture API from C. It creates synthetic press, drag, release, and deep-press
|
||||
events and formats the resulting selection snapshots.
|
||||
|
||||
This uses a `build.zig` and `Zig` to build the C program so that we
|
||||
can reuse a lot of our build logic and depend directly on our source
|
||||
tree, but Ghostty emits a standard C library that can be used with any
|
||||
C tooling.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the program:
|
||||
|
||||
```shell-session
|
||||
zig build run
|
||||
```
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const run_step = b.step("run", "Run the app");
|
||||
|
||||
const exe_mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
exe_mod.addCSourceFiles(.{
|
||||
.root = b.path("src"),
|
||||
.files = &.{"main.c"},
|
||||
});
|
||||
|
||||
// You'll want to use a lazy dependency here so that ghostty is only
|
||||
// downloaded if you actually need it.
|
||||
if (b.lazyDependency("ghostty", .{
|
||||
// Setting simd to false will force a pure static build that
|
||||
// doesn't even require libc, but it has a significant performance
|
||||
// penalty. If your embedding app requires libc anyway, you should
|
||||
// always keep simd enabled.
|
||||
// .simd = false,
|
||||
})) |dep| {
|
||||
exe_mod.linkLibrary(dep.artifact("ghostty-vt"));
|
||||
}
|
||||
|
||||
// Exe
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "c_vt_selection_gesture",
|
||||
.root_module = exe_mod,
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
// Run
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| run_cmd.addArgs(args);
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
.{
|
||||
.name = .c_vt_selection_gesture,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0x5a4e72d27b582404,
|
||||
.minimum_zig_version = "0.15.1",
|
||||
.dependencies = .{
|
||||
// Ghostty dependency. In reality, you'd probably use a URL-based
|
||||
// dependency like the one showed (and commented out) below this one.
|
||||
// We use a path dependency here for simplicity and to ensure our
|
||||
// examples always test against the source they're bundled with.
|
||||
.ghostty = .{ .path = "../../" },
|
||||
|
||||
// Example of what a URL-based dependency looks like:
|
||||
// .ghostty = .{
|
||||
// .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz",
|
||||
// .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s",
|
||||
// },
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
//! [selection-gesture-main]
|
||||
static void vt_write(GhosttyTerminal terminal, const char *s) {
|
||||
ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s));
|
||||
}
|
||||
|
||||
static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) {
|
||||
GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef);
|
||||
GhosttyPoint point = {
|
||||
.tag = GHOSTTY_POINT_TAG_ACTIVE,
|
||||
.value = { .coordinate = { .x = x, .y = y } },
|
||||
};
|
||||
|
||||
GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
return ref;
|
||||
}
|
||||
|
||||
static void print_selection(
|
||||
GhosttyTerminal terminal,
|
||||
const char *label,
|
||||
const GhosttySelection *selection) {
|
||||
GhosttyTerminalSelectionFormatOptions opts =
|
||||
GHOSTTY_INIT_SIZED(GhosttyTerminalSelectionFormatOptions);
|
||||
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
|
||||
opts.trim = true;
|
||||
opts.selection = selection;
|
||||
|
||||
uint8_t *buf = NULL;
|
||||
size_t len = 0;
|
||||
GhosttyResult result = ghostty_terminal_selection_format_alloc(
|
||||
terminal, NULL, opts, &buf, &len);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
printf("%s: ", label);
|
||||
fwrite(buf, 1, len, stdout);
|
||||
printf("\n");
|
||||
|
||||
ghostty_free(NULL, buf, len);
|
||||
}
|
||||
|
||||
static GhosttySelectionGestureEvent new_event(
|
||||
GhosttySelectionGestureEventType type) {
|
||||
GhosttySelectionGestureEvent event = NULL;
|
||||
GhosttyResult result = ghostty_selection_gesture_event_new(NULL, &event, type);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
return event;
|
||||
}
|
||||
|
||||
int main() {
|
||||
GhosttyTerminal terminal;
|
||||
GhosttyTerminalOptions opts = {
|
||||
.cols = 20,
|
||||
.rows = 4,
|
||||
.max_scrollback = 100,
|
||||
};
|
||||
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
vt_write(terminal, "hello world\r\nsecond line");
|
||||
|
||||
GhosttySelectionGesture gesture = NULL;
|
||||
result = ghostty_selection_gesture_new(NULL, &gesture);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttySelectionGestureEvent press =
|
||||
new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS);
|
||||
GhosttySelectionGestureEvent drag =
|
||||
new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG);
|
||||
GhosttySelectionGestureEvent release =
|
||||
new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE);
|
||||
GhosttySelectionGestureEvent deep_press =
|
||||
new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS);
|
||||
|
||||
GhosttySelectionGestureGeometry geometry = {
|
||||
.columns = 20,
|
||||
.cell_width = 10,
|
||||
.padding_left = 0,
|
||||
.screen_height = 40,
|
||||
};
|
||||
|
||||
// Press in the first cell. A normal single press records the click anchor but
|
||||
// doesn't produce a selection yet, so we discard the optional output.
|
||||
GhosttyGridRef press_ref = ref_at(terminal, 0, 0);
|
||||
result = ghostty_selection_gesture_event_set(
|
||||
press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &press_ref);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttySurfacePosition press_pos = { .x = 2, .y = 8 };
|
||||
result = ghostty_selection_gesture_event_set(
|
||||
press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &press_pos);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
result = ghostty_selection_gesture_event(
|
||||
gesture, terminal, press, NULL);
|
||||
assert(result == GHOSTTY_NO_VALUE);
|
||||
|
||||
// Drag across "hello". The drag event returns a selection snapshot that the
|
||||
// embedder can apply to its UI, copy, or format immediately.
|
||||
GhosttyGridRef drag_ref = ref_at(terminal, 4, 0);
|
||||
result = ghostty_selection_gesture_event_set(
|
||||
drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttySurfacePosition drag_pos = { .x = 46, .y = 8 };
|
||||
result = ghostty_selection_gesture_event_set(
|
||||
drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &drag_pos);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
result = ghostty_selection_gesture_event_set(
|
||||
drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY, &geometry);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
|
||||
result = ghostty_selection_gesture_event(
|
||||
gesture, terminal, drag, &selection);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
print_selection(terminal, "drag", &selection);
|
||||
|
||||
// Release updates gesture state but never produces a selection.
|
||||
result = ghostty_selection_gesture_event_set(
|
||||
release, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
result = ghostty_selection_gesture_event(
|
||||
gesture, terminal, release, NULL);
|
||||
assert(result == GHOSTTY_NO_VALUE);
|
||||
|
||||
bool dragged = false;
|
||||
result = ghostty_selection_gesture_get(
|
||||
gesture, terminal, GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED, &dragged);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
printf("dragged: %s\n", dragged ? "true" : "false");
|
||||
|
||||
// Deep press uses the active click anchor to select the surrounding word.
|
||||
ghostty_selection_gesture_reset(gesture, terminal);
|
||||
GhosttyGridRef world_ref = ref_at(terminal, 6, 0);
|
||||
result = ghostty_selection_gesture_event_set(
|
||||
press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &world_ref);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
result = ghostty_selection_gesture_event(
|
||||
gesture, terminal, press, NULL);
|
||||
assert(result == GHOSTTY_NO_VALUE);
|
||||
|
||||
result = ghostty_selection_gesture_event(
|
||||
gesture, terminal, deep_press, &selection);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
print_selection(terminal, "deep press", &selection);
|
||||
|
||||
ghostty_selection_gesture_event_free(deep_press);
|
||||
ghostty_selection_gesture_event_free(release);
|
||||
ghostty_selection_gesture_event_free(drag);
|
||||
ghostty_selection_gesture_event_free(press);
|
||||
ghostty_selection_gesture_free(gesture, terminal);
|
||||
ghostty_terminal_free(terminal);
|
||||
return 0;
|
||||
}
|
||||
//! [selection-gesture-main]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Example: `ghostty-vt` Selection
|
||||
|
||||
This contains a simple example of how to use the `ghostty-vt` terminal,
|
||||
grid reference, selection, and formatter APIs to derive selections such as a
|
||||
word, semantic command line, command output, and all visible content.
|
||||
|
||||
This uses a `build.zig` and `Zig` to build the C program so that we
|
||||
can reuse a lot of our build logic and depend directly on our source
|
||||
tree, but Ghostty emits a standard C library that can be used with any
|
||||
C tooling.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the program:
|
||||
|
||||
```shell-session
|
||||
zig build run
|
||||
```
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const run_step = b.step("run", "Run the app");
|
||||
|
||||
const exe_mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
exe_mod.addCSourceFiles(.{
|
||||
.root = b.path("src"),
|
||||
.files = &.{"main.c"},
|
||||
});
|
||||
|
||||
// You'll want to use a lazy dependency here so that ghostty is only
|
||||
// downloaded if you actually need it.
|
||||
if (b.lazyDependency("ghostty", .{
|
||||
// Setting simd to false will force a pure static build that
|
||||
// doesn't even require libc, but it has a significant performance
|
||||
// penalty. If your embedding app requires libc anyway, you should
|
||||
// always keep simd enabled.
|
||||
// .simd = false,
|
||||
})) |dep| {
|
||||
exe_mod.linkLibrary(dep.artifact("ghostty-vt"));
|
||||
}
|
||||
|
||||
// Exe
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "c_vt_selection",
|
||||
.root_module = exe_mod,
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
// Run
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| run_cmd.addArgs(args);
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
.{
|
||||
.name = .c_vt_selection,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0xb2c2f1a828086fef,
|
||||
.minimum_zig_version = "0.15.1",
|
||||
.dependencies = .{
|
||||
// Ghostty dependency. In reality, you'd probably use a URL-based
|
||||
// dependency like the one showed (and commented out) below this one.
|
||||
// We use a path dependency here for simplicity and to ensure our
|
||||
// examples always test against the source they're bundled with.
|
||||
.ghostty = .{ .path = "../../" },
|
||||
|
||||
// Example of what a URL-based dependency looks like:
|
||||
// .ghostty = .{
|
||||
// .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz",
|
||||
// .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s",
|
||||
// },
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
//! [selection-main]
|
||||
static void vt_write(GhosttyTerminal terminal, const char *s) {
|
||||
ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s));
|
||||
}
|
||||
|
||||
static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) {
|
||||
GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef);
|
||||
GhosttyPoint point = {
|
||||
.tag = GHOSTTY_POINT_TAG_ACTIVE,
|
||||
.value = { .coordinate = { .x = x, .y = y } },
|
||||
};
|
||||
|
||||
GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
return ref;
|
||||
}
|
||||
|
||||
static void print_selection(
|
||||
GhosttyTerminal terminal,
|
||||
const char *label,
|
||||
const GhosttySelection *selection) {
|
||||
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
|
||||
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
|
||||
opts.trim = true;
|
||||
opts.selection = selection;
|
||||
|
||||
GhosttyFormatter formatter;
|
||||
GhosttyResult result = ghostty_formatter_terminal_new(
|
||||
NULL, &formatter, terminal, opts);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
uint8_t *buf = NULL;
|
||||
size_t len = 0;
|
||||
result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
printf("%s: ", label);
|
||||
fwrite(buf, 1, len, stdout);
|
||||
printf("\n");
|
||||
|
||||
ghostty_free(NULL, buf, len);
|
||||
ghostty_formatter_free(formatter);
|
||||
}
|
||||
|
||||
int main() {
|
||||
GhosttyTerminal terminal;
|
||||
GhosttyTerminalOptions opts = {
|
||||
.cols = 80,
|
||||
.rows = 8,
|
||||
.max_scrollback = 0,
|
||||
};
|
||||
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
// A realistic shell transcript with OSC 133 semantic prompt markers.
|
||||
// Ghostty uses these markers to distinguish prompt/input from command
|
||||
// output for semantic line and output selections.
|
||||
vt_write(terminal,
|
||||
"\033]133;A\007$ " // Prompt starts: "$ "
|
||||
"\033]133;B\007git status" // Input starts: "git status"
|
||||
"\033]133;C\007\r\n" // Output starts after Enter
|
||||
"On branch main\r\n"
|
||||
"nothing to commit, working tree clean");
|
||||
|
||||
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
|
||||
|
||||
// Double-click style word selection under the cursor.
|
||||
GhosttyTerminalSelectWordOptions word = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordOptions);
|
||||
word.ref = ref_at(terminal, 6, 0); // the "status" in "git status"
|
||||
result = ghostty_terminal_select_word(terminal, &word, &selection);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
print_selection(terminal, "word", &selection);
|
||||
|
||||
//! [selection-word-between]
|
||||
// Double-click-and-drag style selection. Suppose the user double-clicks
|
||||
// "git" and drags to "status". The pointer may pass over whitespace, so
|
||||
// select the nearest word between the original click and current drag point
|
||||
// in both directions, then combine the outer word bounds.
|
||||
GhosttyGridRef click_ref = ref_at(terminal, 2, 0); // the "git" in "git status"
|
||||
GhosttyGridRef drag_ref = ref_at(terminal, 6, 0); // the "status" in "git status"
|
||||
|
||||
GhosttyTerminalSelectWordBetweenOptions start_word_opts =
|
||||
GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions);
|
||||
start_word_opts.start = click_ref;
|
||||
start_word_opts.end = drag_ref;
|
||||
|
||||
GhosttySelection start_word = GHOSTTY_INIT_SIZED(GhosttySelection);
|
||||
result = ghostty_terminal_select_word_between(
|
||||
terminal, &start_word_opts, &start_word);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttyTerminalSelectWordBetweenOptions end_word_opts =
|
||||
GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions);
|
||||
end_word_opts.start = drag_ref;
|
||||
end_word_opts.end = click_ref;
|
||||
|
||||
GhosttySelection end_word = GHOSTTY_INIT_SIZED(GhosttySelection);
|
||||
result = ghostty_terminal_select_word_between(
|
||||
terminal, &end_word_opts, &end_word);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
|
||||
GhosttySelection drag_selection = GHOSTTY_INIT_SIZED(GhosttySelection);
|
||||
drag_selection.start = start_word.start;
|
||||
drag_selection.end = end_word.end;
|
||||
print_selection(terminal, "double-click drag", &drag_selection);
|
||||
//! [selection-word-between]
|
||||
|
||||
// Triple-click style line selection. With semantic prompt boundaries enabled,
|
||||
// this selects only the input area rather than the leading "$ " prompt.
|
||||
GhosttyTerminalSelectLineOptions line = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectLineOptions);
|
||||
line.ref = ref_at(terminal, 2, 0); // the "git status" input area
|
||||
line.semantic_prompt_boundary = true;
|
||||
result = ghostty_terminal_select_line(terminal, &line, &selection);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
print_selection(terminal, "line", &selection);
|
||||
|
||||
// Select exactly the command output for the command under the cursor.
|
||||
result = ghostty_terminal_select_output(
|
||||
terminal, ref_at(terminal, 0, 1), &selection);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
print_selection(terminal, "output", &selection);
|
||||
|
||||
// Select all visible content.
|
||||
result = ghostty_terminal_select_all(terminal, &selection);
|
||||
assert(result == GHOSTTY_SUCCESS);
|
||||
print_selection(terminal, "all", &selection);
|
||||
|
||||
ghostty_terminal_free(terminal);
|
||||
return 0;
|
||||
}
|
||||
//! [selection-main]
|
||||
|
|
@ -67,9 +67,9 @@
|
|||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz",
|
||||
"dest": "vendor/p/N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ",
|
||||
"sha256": "de263b622090ae12c6707d6754da338ab5f50d6f7f5b244d689084949cdc2b05"
|
||||
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz",
|
||||
"dest": "vendor/p/N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I",
|
||||
"sha256": "1c6ba345d41d5ad5487f71b00a04208d5f656f05b125220324e5aade0397de45"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
|
|
|
|||
|
|
@ -1054,6 +1054,7 @@ typedef union {
|
|||
// apprt.ipc.Action.Key
|
||||
typedef enum {
|
||||
GHOSTTY_IPC_ACTION_NEW_WINDOW,
|
||||
GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL,
|
||||
} ghostty_ipc_action_tag_e;
|
||||
|
||||
//-------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
* - @ref c-vt-sgr/src/main.c - SGR parser example
|
||||
* - @ref c-vt-formatter/src/main.c - Terminal formatter example
|
||||
* - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs
|
||||
* - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example
|
||||
*
|
||||
*/
|
||||
|
||||
|
|
@ -98,6 +99,16 @@
|
|||
* grid refs to inspect cell codepoints, row wrap state, and cell styles.
|
||||
*/
|
||||
|
||||
/** @example c-vt-grid-ref-tracked/src/main.c
|
||||
* This example demonstrates how to track a grid ref as the terminal scrolls,
|
||||
* detect when it loses its value, and move it to a new point.
|
||||
*/
|
||||
|
||||
/** @example c-vt-selection-gesture/src/main.c
|
||||
* This example demonstrates how to use synthetic selection gesture events to
|
||||
* derive drag and deep-press selection snapshots.
|
||||
*/
|
||||
|
||||
/** @example c-vt-kitty-graphics/src/main.c
|
||||
* This example demonstrates how to use the system interface to install a
|
||||
* PNG decoder callback and send a Kitty Graphics Protocol image.
|
||||
|
|
@ -120,6 +131,7 @@ extern "C" {
|
|||
#include <ghostty/vt/render.h>
|
||||
#include <ghostty/vt/terminal.h>
|
||||
#include <ghostty/vt/grid_ref.h>
|
||||
#include <ghostty/vt/grid_ref_tracked.h>
|
||||
#include <ghostty/vt/osc.h>
|
||||
#include <ghostty/vt/sgr.h>
|
||||
#include <ghostty/vt/style.h>
|
||||
|
|
@ -129,6 +141,7 @@ extern "C" {
|
|||
#include <ghostty/vt/modes.h>
|
||||
#include <ghostty/vt/mouse.h>
|
||||
#include <ghostty/vt/paste.h>
|
||||
#include <ghostty/vt/point.h>
|
||||
#include <ghostty/vt/screen.h>
|
||||
#include <ghostty/vt/selection.h>
|
||||
#include <ghostty/vt/size_report.h>
|
||||
|
|
|
|||
|
|
@ -32,23 +32,6 @@ extern "C" {
|
|||
* @{
|
||||
*/
|
||||
|
||||
/**
|
||||
* Output format.
|
||||
*
|
||||
* @ingroup formatter
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** Plain text (no escape sequences). */
|
||||
GHOSTTY_FORMATTER_FORMAT_PLAIN,
|
||||
|
||||
/** VT sequences preserving colors, styles, URLs, etc. */
|
||||
GHOSTTY_FORMATTER_FORMAT_VT,
|
||||
|
||||
/** HTML with inline styles. */
|
||||
GHOSTTY_FORMATTER_FORMAT_HTML,
|
||||
GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyFormatterFormat;
|
||||
|
||||
/**
|
||||
* Extra screen state to include in styled output.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -20,24 +20,79 @@ extern "C" {
|
|||
|
||||
/** @defgroup grid_ref Grid Reference
|
||||
*
|
||||
* A grid reference is a resolved reference to a specific cell position in the
|
||||
* terminal's internal page structure. Obtain a grid reference from
|
||||
* ghostty_terminal_grid_ref(), then extract the cell or row via
|
||||
* ghostty_grid_ref_cell() and ghostty_grid_ref_row().
|
||||
* A grid reference is a reference to a specific cell position in the
|
||||
* terminal. Obtain a grid reference from `ghostty_terminal_grid_ref`
|
||||
* for untracked or `ghostty_terminal_grid_ref_track` for tracked. Untracked
|
||||
* vs tracked is explained next.
|
||||
*
|
||||
* A grid reference is only valid until the next update to the terminal
|
||||
* instance. There is no guarantee that a grid reference will remain
|
||||
* valid after ANY operation, even if a seemingly unrelated part of
|
||||
* the grid is changed, so any information related to the grid reference
|
||||
* should be read and cached immediately after obtaining the grid reference.
|
||||
* Important: The grid reference APIs are not meant to be used as the core of a render
|
||||
* loop. They are not built to sustain the framerates needed for rendering large
|
||||
* screens. Use the render state API for that.
|
||||
*
|
||||
* This API is not meant to be used as the core of render loop. It isn't
|
||||
* built to sustain the framerates needed for rendering large screens.
|
||||
* Use the render state API for that.
|
||||
* ## Untracked vs Tracked References
|
||||
*
|
||||
* ### Untracked Reference
|
||||
*
|
||||
* ## Example
|
||||
* An untracked grid reference is a value type that snapshots a specific
|
||||
* cell. It is only valid until the next update to the terminal instance.
|
||||
* There is no guarantee that it will remain valid after any operation,
|
||||
* even if a seemingly unrelated part of the grid is changed. These are meant
|
||||
* to be read and have their values cached immediately after obtaining it.
|
||||
*
|
||||
* An untracked grid reference has a performance cost in its initial lookup,
|
||||
* but doesn't affect the ongoing performance of the terminal in any way,
|
||||
* since it is a one-time snapshot.
|
||||
*
|
||||
* ### Tracked Reference
|
||||
*
|
||||
* A tracked grid reference follows its cell across normal screen operations.
|
||||
* For example scrolling, scrollback pruning, resize/reflow, and other
|
||||
* terminal mutations update the tracked reference automatically.
|
||||
*
|
||||
* A tracked reference can still lose its original semantic location. This can
|
||||
* happen when the underlying grid is reset, pruned, or otherwise discarded in a
|
||||
* way that cannot be mapped to a meaningful new cell. In that state,
|
||||
* ghostty_tracked_grid_ref_has_value() returns false and
|
||||
* ghostty_tracked_grid_ref_snapshot() / ghostty_tracked_grid_ref_point() return
|
||||
* GHOSTTY_NO_VALUE. The handle remains valid, and callers may move it to a new
|
||||
* point with ghostty_tracked_grid_ref_set().
|
||||
*
|
||||
* To read cell data from a tracked reference, first snapshot it with
|
||||
* ghostty_tracked_grid_ref_snapshot(). The returned `GhosttyGridRef` is again
|
||||
* an untracked reference and follows the same short lifetime rules as any other
|
||||
* untracked grid reference.
|
||||
*
|
||||
* A tracked reference belongs to the terminal screen/page-list that was active
|
||||
* when it was created or last set. Converting it to a point uses that owning
|
||||
* screen/page-list, even if the terminal has since switched between primary and
|
||||
* alternate screens. Calling ghostty_tracked_grid_ref_set() resolves the new
|
||||
* point against the terminal's currently active screen/page-list and may move
|
||||
* the tracked reference between screens.
|
||||
*
|
||||
* Tracked references are owned by the caller and must be freed with
|
||||
* ghostty_tracked_grid_ref_free(). If the terminal that created a tracked
|
||||
* reference is freed first, the handle remains valid only for tracked-grid-ref
|
||||
* APIs: it reports no value and can still be freed.
|
||||
*
|
||||
* Each tracked reference adds bookkeeping to terminal mutations. Use them
|
||||
* sparingly for long-lived anchors such as selections, search state, marks,
|
||||
* or application-side bookmarks.
|
||||
*
|
||||
* ## Lifetime
|
||||
*
|
||||
* An untracked reference is a snapshot. It doesn't need to be freed.
|
||||
* The safety of accessing the value is documented explicitly above: it
|
||||
* is only safe to access any data until the next terminal mutating
|
||||
* operation (including free).
|
||||
*
|
||||
* A tracked reference is allocated and must be freed when it is no
|
||||
* longer needed. A tracked reference may outlive the terminal that created it;
|
||||
* after terminal free, it reports no value and can still be freed.
|
||||
*
|
||||
* ## Examples
|
||||
*
|
||||
* @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse
|
||||
* @snippet c-vt-grid-ref-tracked/src/main.c grid-ref-tracked
|
||||
*
|
||||
* @{
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* @file grid_ref_tracked.h
|
||||
*
|
||||
* Tracked terminal grid references.
|
||||
*/
|
||||
|
||||
#ifndef GHOSTTY_VT_GRID_REF_TRACKED_H
|
||||
#define GHOSTTY_VT_GRID_REF_TRACKED_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <ghostty/vt/types.h>
|
||||
#include <ghostty/vt/grid_ref.h>
|
||||
#include <ghostty/vt/point.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Tracked grid references are owned grid references that move with the
|
||||
* terminal. See @ref grid_ref for the full overview of tracked and untracked
|
||||
* grid reference behavior.
|
||||
*
|
||||
* @ingroup grid_ref
|
||||
*/
|
||||
|
||||
/**
|
||||
* Free a tracked grid reference.
|
||||
*
|
||||
* Passing NULL is allowed and has no effect. A tracked reference may be freed
|
||||
* after the terminal that created it is freed.
|
||||
*
|
||||
* @param ref Tracked grid reference to free.
|
||||
*
|
||||
* @ingroup grid_ref
|
||||
*/
|
||||
GHOSTTY_API void ghostty_tracked_grid_ref_free(GhosttyTrackedGridRef ref);
|
||||
|
||||
/**
|
||||
* Return whether a tracked grid reference currently has a meaningful value.
|
||||
*
|
||||
* If the terminal that created the tracked reference has been freed, this
|
||||
* returns false.
|
||||
*
|
||||
* @param ref Tracked grid reference.
|
||||
* @return true if the reference currently has a meaningful value.
|
||||
*
|
||||
* @ingroup grid_ref
|
||||
*/
|
||||
GHOSTTY_API bool ghostty_tracked_grid_ref_has_value(
|
||||
GhosttyTrackedGridRef ref);
|
||||
|
||||
/**
|
||||
* Convert a tracked grid reference to a point in the requested coordinate
|
||||
* space.
|
||||
*
|
||||
* This is the tracked equivalent of ghostty_terminal_point_from_grid_ref().
|
||||
* Unlike snapshotting, this does not expose an intermediate untracked
|
||||
* GhosttyGridRef.
|
||||
*
|
||||
* A tracked reference is resolved against the terminal screen/page-list that
|
||||
* currently owns the reference. If the terminal has switched between primary
|
||||
* and alternate screens since the reference was created or last set, this may
|
||||
* be different from the terminal's currently active screen.
|
||||
*
|
||||
* If the tracked reference no longer has a meaningful value, this returns
|
||||
* GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot
|
||||
* be represented in the requested coordinate space, including after the
|
||||
* terminal that created the tracked reference has been freed.
|
||||
*
|
||||
* @param ref Tracked grid reference.
|
||||
* @param tag Coordinate space to convert into.
|
||||
* @param[out] out_point On success, receives the coordinate. May be NULL.
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid,
|
||||
* or GHOSTTY_NO_VALUE if there is no representable value.
|
||||
*
|
||||
* @ingroup grid_ref
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_point(
|
||||
GhosttyTrackedGridRef ref,
|
||||
GhosttyPointTag tag,
|
||||
GhosttyPointCoordinate *out_point);
|
||||
|
||||
/**
|
||||
* Move an existing tracked grid reference to a new terminal point.
|
||||
*
|
||||
* On success, the tracked reference begins tracking the new point and any prior
|
||||
* "no value" state is cleared. On GHOSTTY_OUT_OF_MEMORY, the original tracked
|
||||
* reference is left unchanged.
|
||||
*
|
||||
* The terminal must be the same terminal that created the tracked reference.
|
||||
* The point is resolved against the terminal screen/page-list that is active at
|
||||
* the time this function is called. If the terminal has switched between
|
||||
* primary and alternate screens, this may move the tracked reference from one
|
||||
* screen/page-list to the other.
|
||||
*
|
||||
* @param ref Tracked grid reference.
|
||||
* @param terminal Terminal instance that owns the reference.
|
||||
* @param point New point to track.
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref, terminal,
|
||||
* or point is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails.
|
||||
*
|
||||
* @ingroup grid_ref
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_set(
|
||||
GhosttyTrackedGridRef ref,
|
||||
GhosttyTerminal terminal,
|
||||
GhosttyPoint point);
|
||||
|
||||
/**
|
||||
* Snapshot a tracked grid reference into a regular GhosttyGridRef.
|
||||
*
|
||||
* The returned GhosttyGridRef is an untracked snapshot and has the same
|
||||
* lifetime rules as ghostty_terminal_grid_ref(): it is only valid until the
|
||||
* next terminal update. Snapshot immediately before calling
|
||||
* ghostty_grid_ref_cell(), ghostty_grid_ref_row(),
|
||||
* ghostty_grid_ref_graphemes(), ghostty_grid_ref_hyperlink_uri(), or
|
||||
* ghostty_grid_ref_style().
|
||||
*
|
||||
* If the tracked reference no longer has a meaningful value, this returns
|
||||
* GHOSTTY_NO_VALUE. This includes references whose owning terminal has been
|
||||
* freed.
|
||||
*
|
||||
* @param ref Tracked grid reference.
|
||||
* @param[out] out_ref On success, receives an untracked snapshot. May be NULL.
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid,
|
||||
* or GHOSTTY_NO_VALUE if the tracked location was discarded.
|
||||
*
|
||||
* @ingroup grid_ref
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_snapshot(
|
||||
GhosttyTrackedGridRef ref,
|
||||
GhosttyGridRef *out_ref);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* GHOSTTY_VT_GRID_REF_TRACKED_H */
|
||||
|
|
@ -221,6 +221,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
|||
* valid as long as the underlying render state is not updated.
|
||||
* It is unsafe to use cell data after updating the render state. */
|
||||
GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3,
|
||||
|
||||
/** Row-local selected cell range (GhosttyRenderStateRowSelection). */
|
||||
GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4,
|
||||
GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyRenderStateRowData;
|
||||
|
||||
|
|
@ -235,6 +238,29 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
|||
GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyRenderStateRowOption;
|
||||
|
||||
/**
|
||||
* Row-local selection range.
|
||||
*
|
||||
* This struct uses the sized-struct ABI pattern. Initialize with
|
||||
* GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying
|
||||
* GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION.
|
||||
*
|
||||
* Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE
|
||||
* if the current row does not intersect the current selection.
|
||||
*
|
||||
* @ingroup render
|
||||
*/
|
||||
typedef struct {
|
||||
/** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection). */
|
||||
size_t size;
|
||||
|
||||
/** Start column of the row-local selection range, inclusive. */
|
||||
uint16_t start_x;
|
||||
|
||||
/** End column of the row-local selection range, inclusive. */
|
||||
uint16_t end_x;
|
||||
} GhosttyRenderStateRowSelection;
|
||||
|
||||
/**
|
||||
* Render-state color information.
|
||||
*
|
||||
|
|
@ -571,6 +597,36 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
|||
* color, in which case the caller should use whatever default foreground
|
||||
* color it wants (e.g. the terminal foreground). */
|
||||
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6,
|
||||
|
||||
/** Whether the cell is contained within the current selection (bool).
|
||||
* This returns true when the cell's column is within the current row's
|
||||
* row-local selection range, and false otherwise. Rendering policy for
|
||||
* selected cells (colors, inversion, etc.) is left to the caller.
|
||||
*
|
||||
* Renderers that can draw cells in spans may be more efficient querying
|
||||
* GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that
|
||||
* range directly, avoiding one C API call per cell for selection state. */
|
||||
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7,
|
||||
|
||||
/** Whether the cell has any explicit styling (bool).
|
||||
* This is equivalent to querying the raw cell's
|
||||
* GHOSTTY_CELL_DATA_HAS_STYLING value, but avoids materializing the raw
|
||||
* GhosttyCell for renderers that only need to know whether fetching the
|
||||
* full style is necessary. */
|
||||
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING = 8,
|
||||
|
||||
/**
|
||||
* Encode the current cell's full grapheme cluster as UTF-8 into a
|
||||
* caller-provided buffer (GhosttyBuffer).
|
||||
*
|
||||
* The base codepoint is encoded first, followed by any extra grapheme
|
||||
* codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text.
|
||||
*
|
||||
* If ptr is NULL or cap is too small for a non-empty cell, returns
|
||||
* GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required
|
||||
* buffer size in bytes.
|
||||
*/
|
||||
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8 = 9,
|
||||
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyRenderStateRowCellsData;
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,6 +19,7 @@
|
|||
#include <ghostty/vt/kitty_graphics.h>
|
||||
#include <ghostty/vt/screen.h>
|
||||
#include <ghostty/vt/point.h>
|
||||
#include <ghostty/vt/selection.h>
|
||||
#include <ghostty/vt/style.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
|
@ -592,6 +593,21 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
|||
* Input type: size_t*
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY = 20,
|
||||
|
||||
/**
|
||||
* Set the active screen selection.
|
||||
*
|
||||
* The value must point to a GhosttySelection whose grid references are
|
||||
* valid for this terminal's active screen at the time of the call. The
|
||||
* terminal copies the selection immediately and converts it to
|
||||
* terminal-owned tracked state, so the GhosttySelection struct and its
|
||||
* untracked grid references do not need to outlive this call.
|
||||
*
|
||||
* Passing NULL clears the active screen selection.
|
||||
*
|
||||
* Input type: GhosttySelection*
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_SELECTION = 21,
|
||||
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalOption;
|
||||
|
||||
|
|
@ -868,6 +884,33 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
|||
* Output type: GhosttyKittyGraphics *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30,
|
||||
|
||||
/**
|
||||
* The active screen's current selection.
|
||||
*
|
||||
* On success, writes an untracked snapshot of the terminal-owned selection
|
||||
* to the caller-provided GhosttySelection. The GhosttySelection struct is
|
||||
* caller-owned and may be kept, but the grid references inside it are
|
||||
* untracked borrowed references into the active screen. They are only valid
|
||||
* until the next mutating terminal call, such as ghostty_terminal_set(),
|
||||
* ghostty_terminal_vt_write(), ghostty_terminal_resize(), or
|
||||
* ghostty_terminal_reset().
|
||||
*
|
||||
* Returns GHOSTTY_NO_VALUE when there is no active selection.
|
||||
*
|
||||
* Output type: GhosttySelection *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_SELECTION = 31,
|
||||
|
||||
/**
|
||||
* Whether the viewport is currently pinned to the active area.
|
||||
*
|
||||
* This is true when the viewport is following the active terminal area,
|
||||
* and false when the user has scrolled into history.
|
||||
*
|
||||
* Output type: bool *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE = 32,
|
||||
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalData;
|
||||
|
||||
|
|
@ -1120,6 +1163,38 @@ GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal,
|
|||
GhosttyPoint point,
|
||||
GhosttyGridRef *out_ref);
|
||||
|
||||
/**
|
||||
* Create an owned tracked grid reference for a terminal point.
|
||||
*
|
||||
* This is the tracked variant of ghostty_terminal_grid_ref(). The returned
|
||||
* handle follows the referenced cell as the terminal's page list is modified:
|
||||
* scrolling, pruning, resize/reflow, and other page-list operations update the
|
||||
* tracked reference automatically.
|
||||
*
|
||||
* The reference is attached to the terminal screen/page-list that is active at
|
||||
* creation time.
|
||||
*
|
||||
* If the point is outside the requested coordinate space, this returns
|
||||
* GHOSTTY_INVALID_VALUE and writes NULL to out_ref.
|
||||
*
|
||||
* The returned handle must be freed with ghostty_tracked_grid_ref_free(). If
|
||||
* the terminal is freed first, the handle remains valid only for
|
||||
* tracked-grid-ref APIs: it reports no value and can still be freed.
|
||||
*
|
||||
* @param terminal Terminal instance.
|
||||
* @param point Point to track.
|
||||
* @param[out] out_ref On success, receives the tracked reference handle.
|
||||
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if terminal,
|
||||
* point, or out_ref is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation
|
||||
* fails.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref_track(
|
||||
GhosttyTerminal terminal,
|
||||
GhosttyPoint point,
|
||||
GhosttyTrackedGridRef *out_ref);
|
||||
|
||||
/**
|
||||
* Convert a grid reference back to a point in the given coordinate system.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -94,6 +94,18 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
|||
*/
|
||||
typedef struct GhosttyTerminalImpl* GhosttyTerminal;
|
||||
|
||||
/**
|
||||
* Opaque handle to a tracked grid reference.
|
||||
*
|
||||
* A tracked grid reference is owned by the caller and must be freed with
|
||||
* ghostty_tracked_grid_ref_free(). If the terminal that created it is freed
|
||||
* first, the handle remains valid only for tracked-grid-ref APIs: it reports no
|
||||
* value and can still be freed.
|
||||
*
|
||||
* @ingroup grid_ref
|
||||
*/
|
||||
typedef struct GhosttyTrackedGridRefImpl* GhosttyTrackedGridRef;
|
||||
|
||||
/**
|
||||
* Opaque handle to a Kitty graphics image storage.
|
||||
*
|
||||
|
|
@ -184,6 +196,23 @@ typedef struct GhosttyOscCommandImpl* GhosttyOscCommand;
|
|||
|
||||
/* ---- Common value types ---- */
|
||||
|
||||
/**
|
||||
* Terminal content output format.
|
||||
*
|
||||
* @ingroup formatter
|
||||
*/
|
||||
typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/** Plain text (no escape sequences). */
|
||||
GHOSTTY_FORMATTER_FORMAT_PLAIN,
|
||||
|
||||
/** VT sequences preserving colors, styles, URLs, etc. */
|
||||
GHOSTTY_FORMATTER_FORMAT_VT,
|
||||
|
||||
/** HTML with inline styles. */
|
||||
GHOSTTY_FORMATTER_FORMAT_HTML,
|
||||
GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyFormatterFormat;
|
||||
|
||||
/**
|
||||
* A borrowed byte string (pointer + length).
|
||||
*
|
||||
|
|
@ -198,6 +227,55 @@ typedef struct {
|
|||
size_t len;
|
||||
} GhosttyString;
|
||||
|
||||
/**
|
||||
* A caller-provided byte buffer.
|
||||
*
|
||||
* APIs that write to this type use `len` for the number of bytes written on
|
||||
* GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE.
|
||||
*/
|
||||
typedef struct {
|
||||
/** Destination buffer for bytes. May be NULL when cap is 0 to query required size. */
|
||||
uint8_t* ptr;
|
||||
|
||||
/** Capacity of ptr in bytes. */
|
||||
size_t cap;
|
||||
|
||||
/** Bytes written on success, or required byte capacity on GHOSTTY_OUT_OF_SPACE. */
|
||||
size_t len;
|
||||
} GhosttyBuffer;
|
||||
|
||||
/**
|
||||
* A surface-space position in pixels.
|
||||
*
|
||||
* This is not a terminal grid coordinate. It represents an x/y position in the
|
||||
* rendered surface coordinate space, with (0, 0) at the top-left of the
|
||||
* surface.
|
||||
*/
|
||||
typedef struct {
|
||||
/** X position in surface pixels. */
|
||||
double x;
|
||||
|
||||
/** Y position in surface pixels. */
|
||||
double y;
|
||||
} GhosttySurfacePosition;
|
||||
|
||||
/**
|
||||
* A borrowed list of Unicode scalar values.
|
||||
*
|
||||
* Values are encoded as uint32_t scalar values. The memory is not owned by this
|
||||
* struct. The pointer is only valid for the lifetime documented by the API that
|
||||
* consumes or produces it.
|
||||
*
|
||||
* APIs may document special handling for NULL + len 0, such as “use defaults”.
|
||||
*/
|
||||
typedef struct {
|
||||
/** Pointer to Unicode scalar values. */
|
||||
const uint32_t* ptr;
|
||||
|
||||
/** Number of entries in ptr. */
|
||||
size_t len;
|
||||
} GhosttyCodepoints;
|
||||
|
||||
/**
|
||||
* Initialize a sized struct to zero and set its size field.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@
|
|||
Helpers/CrossKit.swift,
|
||||
"Helpers/Extensions/NSImage+Extension.swift",
|
||||
"Helpers/Extensions/OSColor+Extension.swift",
|
||||
"Helpers/Extensions/OSPasteboard+Extension.swift",
|
||||
);
|
||||
target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -404,20 +404,7 @@ class AppDelegate: NSObject,
|
|||
// If our app says we don't need to confirm, we can exit now.
|
||||
if !ghostty.needsConfirmQuit { return .terminateNow }
|
||||
|
||||
// We have some visible window. Show an app-wide modal to confirm quitting.
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Quit Ghostty?"
|
||||
alert.informativeText = "All terminal sessions will be terminated."
|
||||
alert.addButton(withTitle: "Close Ghostty")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return .terminateNow
|
||||
|
||||
default:
|
||||
return .terminateCancel
|
||||
}
|
||||
return terminate()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
|
|
@ -620,7 +607,7 @@ class AppDelegate: NSObject,
|
|||
// Build our event input and call ghostty
|
||||
if ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
|
||||
// The key was used so we want to stop it from going to our Mac app
|
||||
Ghostty.logger.debug("local key event handled event=\(event)")
|
||||
Ghostty.logger.debug("local key event handled event=\(event, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -675,7 +662,7 @@ class AppDelegate: NSObject,
|
|||
private func requestBadgeAuthorizationAndSet(_ center: UNUserNotificationCenter) {
|
||||
center.requestAuthorization(options: [.badge]) { granted, error in
|
||||
if let error = error {
|
||||
Self.logger.warning("Error requesting badge authorization: \(error)")
|
||||
Self.logger.warning("Error requesting badge authorization: \(error, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1305,6 +1292,79 @@ extension AppDelegate: NSMenuItemValidation {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Termination Flow
|
||||
|
||||
extension AppDelegate {
|
||||
func terminate() -> NSApplication.TerminateReply {
|
||||
let controllersNeedConfirmation = NSApplication.shared.windows
|
||||
.compactMap { $0.windowController as? BaseTerminalController }
|
||||
.filter { !$0.windowCanBeClosedWithoutConfirmation() }
|
||||
|
||||
guard !controllersNeedConfirmation.isEmpty else {
|
||||
return .terminateNow
|
||||
}
|
||||
|
||||
if controllersNeedConfirmation.count == 1 {
|
||||
Task {
|
||||
let response = await controllersNeedConfirmation[0].confirmCloseAsync(
|
||||
messageText: "Quit Ghostty?",
|
||||
informativeText: "The terminal still has a running process. If you quit, the process will be killed.",
|
||||
confirmButtonTitle: "Terminate",
|
||||
)
|
||||
|
||||
if [.OK, .alertFirstButtonReturn].contains(response) {
|
||||
await NSApp.reply(toApplicationShouldTerminate: true)
|
||||
} else {
|
||||
await NSApp.reply(toApplicationShouldTerminate: false)
|
||||
}
|
||||
}
|
||||
|
||||
return .terminateLater
|
||||
} else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "You have \(controllersNeedConfirmation.count) windows with running processes. Do you want to review these windows before quitting?"
|
||||
alert.informativeText = "If you don't review your windows, any running processes will be terminated"
|
||||
alert.addButton(withTitle: "Review Windows...")
|
||||
alert.addButton(withTitle: "Terminate Processes")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
reviewWindows(controllersNeedConfirmation)
|
||||
return .terminateLater
|
||||
case .alertSecondButtonReturn:
|
||||
return .terminateNow
|
||||
default:
|
||||
return .terminateCancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewWindows(_ controllers: [BaseTerminalController]) {
|
||||
Task {
|
||||
for controller in controllers {
|
||||
let response = await controller.confirmCloseAsync(
|
||||
messageText: "Quit Ghostty?",
|
||||
informativeText: "The terminal still has a running process. If you quit, the process will be killed.",
|
||||
confirmButtonTitle: "Terminate",
|
||||
)
|
||||
|
||||
if [.OK, .alertFirstButtonReturn].contains(response) {
|
||||
// Close this window and until next review is cancelled
|
||||
await controller.window?.close()
|
||||
continue
|
||||
} else {
|
||||
await NSApp.reply(toApplicationShouldTerminate: false)
|
||||
// Cancel the review
|
||||
return
|
||||
}
|
||||
}
|
||||
await NSApp.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the state of the quick terminal controller.
|
||||
private enum QuickTerminalState {
|
||||
/// Controller has not been initialized and has no pending restoration state.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class AboutController: NSWindowController, NSWindowDelegate {
|
|||
window.center()
|
||||
window.isMovableByWindowBackground = true
|
||||
window.contentView = NSHostingView(rootView: AboutView().environmentObject(viewModel))
|
||||
window.titlebarAppearsTransparent = true
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class GlobalEventTap {
|
|||
|
||||
// The event tap used for global event listening. This is non-nil if it is
|
||||
// created.
|
||||
private var eventTap: CFMachPort?
|
||||
fileprivate var eventTap: CFMachPort?
|
||||
|
||||
// This is the timer used to retry enabling the global event tap if we
|
||||
// don't have permissions.
|
||||
|
|
@ -125,6 +125,17 @@ private func cgEventFlagsChangedHandler(
|
|||
) -> Unmanaged<CGEvent>? {
|
||||
let result = Unmanaged.passUnretained(cgEvent)
|
||||
|
||||
// macOS disables the event tap if the callback is too slow or for other
|
||||
// internal reasons. When that happens it sends this event type. We need
|
||||
// to re-enable the tap or it stays dead forever.
|
||||
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
||||
GlobalEventTap.logger.warning("global event tap was disabled by the system, re-enabling")
|
||||
if let machPort = GlobalEventTap.shared.eventTap {
|
||||
CGEvent.tapEnable(tap: machPort, enable: true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// We only care about keydown events
|
||||
guard type == .keyDown else { return result }
|
||||
|
||||
|
|
@ -143,7 +154,7 @@ private func cgEventFlagsChangedHandler(
|
|||
// Build our event input and call ghostty
|
||||
let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
|
||||
if ghostty_app_key(ghostty, key_ev) {
|
||||
GlobalEventTap.logger.info("global key event handled event=\(event)")
|
||||
GlobalEventTap.logger.info("global key event handled event=\(event, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,16 +44,6 @@ extension QuickTerminalRestorableState {
|
|||
let focusedSurface: String?
|
||||
let surfaceTree: SplitTree<ViewType>
|
||||
let screenStateEntries: QuickTerminalScreenStateCache.Entries
|
||||
|
||||
init(
|
||||
focusedSurface: String?,
|
||||
surfaceTree: SplitTree<ViewType>,
|
||||
screenStateEntries: QuickTerminalScreenStateCache.Entries,
|
||||
) {
|
||||
self.focusedSurface = focusedSurface
|
||||
self.surfaceTree = surfaceTree
|
||||
self.screenStateEntries = screenStateEntries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,11 +97,11 @@ class SecureInput: ObservableObject {
|
|||
}
|
||||
if err == noErr {
|
||||
enabled = desired
|
||||
Self.logger.debug("secure input state=\(self.enabled)")
|
||||
Self.logger.debug("secure input state=\(self.enabled, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
Self.logger.warning("secure input apply failed err=\(err)")
|
||||
Self.logger.warning("secure input apply failed err=\(err, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
|
@ -117,7 +117,7 @@ class SecureInput: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
Self.logger.warning("secure input apply failed err=\(err)")
|
||||
Self.logger.warning("secure input apply failed err=\(err, privacy: .public)")
|
||||
}
|
||||
|
||||
@objc private func onDidResignActive(notification: NSNotification) {
|
||||
|
|
@ -130,6 +130,6 @@ class SecureInput: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
Self.logger.warning("secure input apply failed err=\(err)")
|
||||
Self.logger.warning("secure input apply failed err=\(err, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,10 +42,21 @@ class ServiceProvider: NSObject {
|
|||
// to their directories because that's the only thing we can open.
|
||||
let directoryURLs = Set(
|
||||
pathURLs.map { url -> URL in
|
||||
url.hasDirectoryPath ? url : url.deletingLastPathComponent()
|
||||
/// We check file system resources here because
|
||||
/// NSURL doesn't append `/` when reading string contents from pasteboard
|
||||
/// ```
|
||||
/// NSURL(pasteboardPropertyList: "/System/Library".propertyList(), ofType: .fileURL)?.hasDirectoryPath
|
||||
/// ```
|
||||
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? url.hasDirectoryPath
|
||||
return isDirectory ? url : url.deletingLastPathComponent()
|
||||
}
|
||||
)
|
||||
|
||||
guard !directoryURLs.isEmpty else {
|
||||
error.pointee = Self.errorNoString
|
||||
return
|
||||
}
|
||||
|
||||
for url in directoryURLs {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.workingDirectory = url.path(percentEncoded: false)
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi
|
|||
window.center()
|
||||
window.level = .popUpMenu
|
||||
window.contentView = NSHostingView(rootView: ConfigurationErrorsView(model: self))
|
||||
window.titlebarAppearsTransparent = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,8 +46,11 @@ struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
|
|||
HStack {
|
||||
Spacer()
|
||||
Button("Ignore") { model.errors = [] }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Reload Configuration") { reloadConfig() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.controlSize(.large)
|
||||
.padding([.bottom, .trailing])
|
||||
}
|
||||
.frame(minWidth: 480, maxWidth: 960, minHeight: 270)
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ class BaseTerminalController: NSWindowController,
|
|||
// 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)")
|
||||
Ghostty.logger.warning("failed to insert split: \(error, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -292,6 +292,7 @@ class BaseTerminalController: NSWindowController,
|
|||
if to.isEmpty {
|
||||
focusedSurface = nil
|
||||
}
|
||||
syncSurfaceTreeOcclusionState()
|
||||
}
|
||||
|
||||
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
|
||||
|
|
@ -316,19 +317,18 @@ class BaseTerminalController: NSWindowController,
|
|||
savedFrame = .init(window: window.frame, screen: screen.visibleFrame)
|
||||
}
|
||||
|
||||
func confirmClose(
|
||||
func confirmCloseAsync(
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
confirmButtonTitle: String = "Close",
|
||||
) async -> NSApplication.ModalResponse? {
|
||||
// If we already have an alert, we need to wait for that one.
|
||||
guard alert == nil else { return }
|
||||
guard alert == nil else { return nil }
|
||||
|
||||
// If there is no window to attach the modal then we assume success
|
||||
// since we'll never be able to show the modal.
|
||||
guard let window else {
|
||||
completion()
|
||||
return
|
||||
return .OK
|
||||
}
|
||||
|
||||
// If we need confirmation by any, show one confirmation for all windows
|
||||
|
|
@ -336,22 +336,35 @@ class BaseTerminalController: NSWindowController,
|
|||
let alert = NSAlert()
|
||||
alert.messageText = messageText
|
||||
alert.informativeText = informativeText
|
||||
alert.addButton(withTitle: "Close")
|
||||
alert.addButton(withTitle: confirmButtonTitle)
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
alert.beginSheetModal(for: window) { response in
|
||||
let alertWindow = alert.window
|
||||
// Store our alert so we only ever show one.
|
||||
self.alert = alert
|
||||
defer {
|
||||
// This is important so that we avoid losing focus when Stage
|
||||
// Manager is used (#8336)
|
||||
alert.window.orderOut(nil)
|
||||
self.alert = nil
|
||||
if response == .alertFirstButtonReturn {
|
||||
// This is important so that we avoid losing focus when Stage
|
||||
// Manager is used (#8336)
|
||||
alertWindow.orderOut(nil)
|
||||
}
|
||||
return await alert.beginSheetModal(for: window)
|
||||
}
|
||||
|
||||
func confirmClose(
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
confirmButtonTitle: String = "Close",
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
Task {
|
||||
guard let response = await confirmCloseAsync(messageText: messageText, informativeText: informativeText, confirmButtonTitle: confirmButtonTitle) else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
if [.alertFirstButtonReturn, .OK].contains(response) {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
// Store our alert so we only ever show one.
|
||||
self.alert = alert
|
||||
}
|
||||
|
||||
/// Prompt the user to change the tab/window title.
|
||||
|
|
@ -458,8 +471,12 @@ class BaseTerminalController: NSWindowController,
|
|||
|
||||
replaceSurfaceTree(
|
||||
surfaceTree.removing(node),
|
||||
moveFocusTo: nextFocus,
|
||||
moveFocusFrom: focusedSurface,
|
||||
// When a non-focused surface is removed and this window stays as the key window,
|
||||
// we should refocus the `focusedSurface` to make sure the window's firstResponder remains as it is.
|
||||
//
|
||||
// This is a weird workaround, since `resignFirstResponder` wasn't called on `focusedSurface` after drag,
|
||||
// but the first responder became the window itself.
|
||||
moveFocusTo: nextFocus ?? focusedSurface,
|
||||
undoAction: "Close Terminal"
|
||||
)
|
||||
}
|
||||
|
|
@ -713,7 +730,7 @@ class BaseTerminalController: NSWindowController,
|
|||
do {
|
||||
surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to resize split: \(error)")
|
||||
Ghostty.logger.warning("failed to resize split: \(error, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -766,7 +783,8 @@ class BaseTerminalController: NSWindowController,
|
|||
ghostty,
|
||||
tree: newTree,
|
||||
position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint,
|
||||
confirmUndo: false)
|
||||
confirmUndo: false,
|
||||
inheritBackgroundOpacity: isBackgroundOpaque)
|
||||
}
|
||||
|
||||
// MARK: Local Events
|
||||
|
|
@ -886,7 +904,7 @@ class BaseTerminalController: NSWindowController,
|
|||
do {
|
||||
surfaceTree = try surfaceTree.replacing(node: node, with: resizedNode)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
|
||||
Ghostty.logger.warning("failed to replace node during split resize: \(error, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -911,7 +929,7 @@ class BaseTerminalController: NSWindowController,
|
|||
do {
|
||||
newTree = try treeWithoutSource.inserting(view: source, at: destination, direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert surface during drop: \(error)")
|
||||
Ghostty.logger.warning("failed to insert surface during drop: \(error, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -948,7 +966,7 @@ class BaseTerminalController: NSWindowController,
|
|||
do {
|
||||
newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)")
|
||||
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -990,11 +1008,15 @@ class BaseTerminalController: NSWindowController,
|
|||
// Do nothing if in fullscreen (transparency doesn't apply in fullscreen)
|
||||
guard let window, !window.styleMask.contains(.fullScreen) else { return }
|
||||
|
||||
// Toggle between transparent and opaque
|
||||
isBackgroundOpaque.toggle()
|
||||
let newValue = !isBackgroundOpaque
|
||||
let controllers = NSApplication.shared.windows.compactMap {
|
||||
$0.windowController as? BaseTerminalController
|
||||
}
|
||||
|
||||
// Update our appearance
|
||||
syncAppearance()
|
||||
for controller in controllers {
|
||||
controller.isBackgroundOpaque = newValue
|
||||
controller.syncAppearance()
|
||||
}
|
||||
}
|
||||
|
||||
/// Override this to resync any appearance related properties. This will be called automatically
|
||||
|
|
@ -1178,10 +1200,8 @@ class BaseTerminalController: NSWindowController,
|
|||
|
||||
// MARK: NSWindowDelegate
|
||||
|
||||
// This is called when performClose is called on a window (NOT when close()
|
||||
// is called directly). performClose is called primarily when UI elements such
|
||||
// as the "red X" are pressed.
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
/// Check whether window should be closed without showing an alert
|
||||
func windowCanBeClosedWithoutConfirmation() -> Bool {
|
||||
// We must have a window. Is it even possible not to?
|
||||
guard let window = self.window else { return true }
|
||||
|
||||
|
|
@ -1194,12 +1214,22 @@ class BaseTerminalController: NSWindowController,
|
|||
// If our surfaces don't require confirmation, close.
|
||||
if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// This is called when performClose is called on a window (NOT when close()
|
||||
// is called directly). performClose is called primarily when UI elements such
|
||||
// as the "red X" are pressed.
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
guard !windowCanBeClosedWithoutConfirmation() else {
|
||||
return true
|
||||
}
|
||||
// We require confirmation, so show an alert as long as we aren't already.
|
||||
confirmClose(
|
||||
messageText: "Close Terminal?",
|
||||
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
|
||||
) {
|
||||
window.close()
|
||||
) { [weak self] in
|
||||
self?.window?.close()
|
||||
}
|
||||
|
||||
return false
|
||||
|
|
@ -1252,10 +1282,15 @@ class BaseTerminalController: NSWindowController,
|
|||
}
|
||||
|
||||
func windowDidChangeOcclusionState(_ notification: Notification) {
|
||||
syncSurfaceTreeOcclusionState()
|
||||
}
|
||||
|
||||
private func syncSurfaceTreeOcclusionState() {
|
||||
let visible = self.window?.occlusionState.contains(.visible) ?? false
|
||||
for view in surfaceTree {
|
||||
if let surface = view.surface {
|
||||
if let surface = view.surface, view.isWindowVisible != visible {
|
||||
ghostty_surface_set_occlusion(surface, visible)
|
||||
view.isWindowVisible = visible
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,6 +256,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
|||
// Get our parent. Our parent is the one explicitly given to us,
|
||||
// otherwise the focused terminal, otherwise an arbitrary one.
|
||||
let parent: NSWindow? = explicitParent ?? preferredParent?.window
|
||||
if let parentController = parent?.windowController as? TerminalController {
|
||||
c.isBackgroundOpaque = parentController.isBackgroundOpaque
|
||||
}
|
||||
|
||||
if let parent, parent.styleMask.contains(.fullScreen) {
|
||||
// If our previous window was fullscreen then we want our new window to
|
||||
|
|
@ -339,8 +342,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
|||
tree: SplitTree<Ghostty.SurfaceView>,
|
||||
position: NSPoint? = nil,
|
||||
confirmUndo: Bool = true,
|
||||
inheritBackgroundOpacity: Bool? = nil
|
||||
) -> TerminalController {
|
||||
let c = TerminalController.init(ghostty, withSurfaceTree: tree)
|
||||
if let inheritBackgroundOpacity {
|
||||
c.isBackgroundOpaque = inheritBackgroundOpacity
|
||||
}
|
||||
|
||||
// Calculate the target frame based on the tree's view bounds
|
||||
let treeSize: CGSize? = tree.root?.viewBounds()
|
||||
|
|
@ -385,7 +392,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
|||
withTarget: ghostty,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { ghostty in
|
||||
_ = TerminalController.newWindow(ghostty, tree: tree)
|
||||
_ = TerminalController.newWindow(
|
||||
ghostty,
|
||||
tree: tree,
|
||||
inheritBackgroundOpacity: inheritBackgroundOpacity
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -420,6 +431,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
|||
|
||||
// Create a new window and add it to the parent
|
||||
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig)
|
||||
controller.isBackgroundOpaque = parentController.isBackgroundOpaque
|
||||
guard let window = controller.window else { return controller }
|
||||
|
||||
// If the parent is miniaturized, then macOS exhibits really strange behaviors
|
||||
|
|
@ -1398,9 +1410,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
|||
|
||||
// We also want to get notified of certain changes to update our appearance.
|
||||
focusedSurface.$derivedConfig
|
||||
.dropFirst()
|
||||
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
|
||||
.store(in: &surfaceAppearanceCancellables)
|
||||
focusedSurface.$backgroundColor
|
||||
.dropFirst()
|
||||
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
|
||||
.store(in: &surfaceAppearanceCancellables)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ extension TerminalRestorable {
|
|||
coder.encode(Self.version, forKey: Self.versionKey)
|
||||
coder.encode(CodableBridge(self), forKey: Self.selfKey)
|
||||
|
||||
AppDelegate.logger.debug("saved terminal state: \(debugDescription)")
|
||||
AppDelegate.logger.debug("saved terminal state: \(debugDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,20 +15,6 @@ extension TerminalRestorableState {
|
|||
let effectiveFullscreenMode: FullscreenMode?
|
||||
let tabColor: TerminalTabColor?
|
||||
let titleOverride: String?
|
||||
|
||||
init(
|
||||
focusedSurface: String?,
|
||||
surfaceTree: SplitTree<ViewType>,
|
||||
effectiveFullscreenMode: FullscreenMode?,
|
||||
tabColor: TerminalTabColor?,
|
||||
titleOverride: String?,
|
||||
) {
|
||||
self.focusedSurface = focusedSurface
|
||||
self.surfaceTree = surfaceTree
|
||||
self.effectiveFullscreenMode = effectiveFullscreenMode
|
||||
self.tabColor = tabColor
|
||||
self.titleOverride = titleOverride
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -190,14 +190,14 @@ extension Ghostty {
|
|||
func newTab(surface: ghostty_surface_t) {
|
||||
let action = "new_tab"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func newWindow(surface: ghostty_surface_t) {
|
||||
let action = "new_window"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,14 +220,14 @@ extension Ghostty {
|
|||
func splitToggleZoom(surface: ghostty_surface_t) {
|
||||
let action = "toggle_split_zoom"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFullscreen(surface: ghostty_surface_t) {
|
||||
let action = "toggle_fullscreen"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,21 +248,21 @@ extension Ghostty {
|
|||
action = "reset_font_size"
|
||||
}
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleTerminalInspector(surface: ghostty_surface_t) {
|
||||
let action = "inspector:toggle"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func resetTerminal(surface: ghostty_surface_t) {
|
||||
let action = "reset"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -485,7 +485,7 @@ extension Ghostty {
|
|||
break
|
||||
|
||||
default:
|
||||
Ghostty.logger.warning("unknown action target=\(target.tag.rawValue)")
|
||||
Ghostty.logger.warning("unknown action target=\(target.tag.rawValue, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -672,7 +672,7 @@ extension Ghostty {
|
|||
case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD:
|
||||
return copyTitleToClipboard(app, target: target)
|
||||
default:
|
||||
Ghostty.logger.warning("unknown action action=\(action.tag.rawValue)")
|
||||
Ghostty.logger.warning("unknown action action=\(action.tag.rawValue, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -979,7 +979,7 @@ extension Ghostty {
|
|||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let mode = FullscreenMode.from(ghostty: raw) else {
|
||||
Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)")
|
||||
Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue, privacy: .public)")
|
||||
return
|
||||
}
|
||||
NotificationCenter.default.post(
|
||||
|
|
@ -1399,7 +1399,7 @@ extension Ghostty {
|
|||
let center = UNUserNotificationCenter.current()
|
||||
center.requestAuthorization(options: [.alert, .sound]) { _, error in
|
||||
if let error = error {
|
||||
Ghostty.logger.error("Error while requesting notification authorization: \(error)")
|
||||
Ghostty.logger.error("Error while requesting notification authorization: \(error, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,13 +95,13 @@ extension Ghostty {
|
|||
// pop-up window too.
|
||||
let diagsCount = ghostty_config_diagnostics_count(cfg)
|
||||
if diagsCount > 0 {
|
||||
logger.warning("config error: \(diagsCount) configuration errors on reload")
|
||||
logger.warning("config error: \(diagsCount, privacy: .public) configuration errors on reload")
|
||||
var diags: [String] = []
|
||||
for i in 0..<diagsCount {
|
||||
let diag = ghostty_config_get_diagnostic(cfg, UInt32(i))
|
||||
let message = String(cString: diag.message)
|
||||
diags.append(message)
|
||||
logger.warning("config error: \(message)")
|
||||
logger.warning("config error: \(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
import GhosttyKit
|
||||
import SwiftUI
|
||||
|
||||
extension Ghostty {
|
||||
class OSSurfaceView: OSView, ObservableObject {
|
||||
|
|
@ -115,13 +116,42 @@ extension Ghostty {
|
|||
// MARK: Search State
|
||||
|
||||
extension Ghostty.OSSurfaceView {
|
||||
class SearchState: ObservableObject {
|
||||
@MainActor class SearchState: ObservableObject {
|
||||
/// The pasteboard used to persist the search needle.
|
||||
///
|
||||
/// The `.find` pasteboard lets us sync our needle across the system and other find bars.
|
||||
private let pasteboard: OSPasteboard
|
||||
|
||||
@Published var needle: String = ""
|
||||
@Published var selected: UInt?
|
||||
@Published var total: UInt?
|
||||
|
||||
init(from startSearch: Ghostty.Action.StartSearch) {
|
||||
self.needle = startSearch.needle ?? ""
|
||||
/// The range of the needle's text selection in the find bar.
|
||||
@Published var needleSelection: Range<String.Index>?
|
||||
|
||||
init(
|
||||
from startSearch: Ghostty.Action.StartSearch,
|
||||
pasteboard: OSPasteboard = OSPasteboard.find
|
||||
) {
|
||||
self.pasteboard = pasteboard
|
||||
if let needle = startSearch.needle, !needle.isEmpty {
|
||||
self.needle = needle
|
||||
writePasteboardNeedle()
|
||||
} else {
|
||||
readPasteboardNeedle()
|
||||
}
|
||||
}
|
||||
|
||||
func readPasteboardNeedle() {
|
||||
let pasteboardNeedle = pasteboard.string
|
||||
if let pasteboardNeedle, pasteboardNeedle != needle {
|
||||
needle = pasteboardNeedle
|
||||
needleSelection = needle.startIndex..<needle.endIndex
|
||||
}
|
||||
}
|
||||
|
||||
func writePasteboardNeedle() {
|
||||
pasteboard.string = needle
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +160,7 @@ extension Ghostty.OSSurfaceView {
|
|||
let action = "navigate_search:next"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
#if canImport(AppKit)
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
|
@ -142,7 +172,7 @@ extension Ghostty.OSSurfaceView {
|
|||
let action = "navigate_search:previous"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
#if canImport(AppKit)
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import SwiftUI
|
||||
import UserNotifications
|
||||
import GhosttyKit
|
||||
import System
|
||||
|
||||
extension Ghostty {
|
||||
/// Render a terminal for the active app in the environment.
|
||||
|
|
@ -376,7 +377,11 @@ extension Ghostty {
|
|||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 4) {
|
||||
TextField("Search", text: $searchState.needle)
|
||||
BackportSelectionTextField(
|
||||
"Search",
|
||||
text: $searchState.needle,
|
||||
selection: $searchState.needleSelection
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.frame(width: 180)
|
||||
.padding(.leading, 8)
|
||||
|
|
@ -400,6 +405,21 @@ extension Ghostty {
|
|||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
.onChange(of: searchState.needle) { _ in
|
||||
searchState.writePasteboardNeedle()
|
||||
}
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(
|
||||
for: OSApplication.didBecomeActiveNotification
|
||||
)
|
||||
) { _ in
|
||||
// When the app becomes active, we want to check for external changes
|
||||
// to our synced needle.
|
||||
searchState.readPasteboardNeedle()
|
||||
}
|
||||
.onSubmit {
|
||||
_ = surfaceView.navigateSearchToNext()
|
||||
}
|
||||
#if canImport(AppKit)
|
||||
.onExitCommand {
|
||||
if searchState.needle.isEmpty {
|
||||
|
|
@ -412,10 +432,9 @@ extension Ghostty {
|
|||
.backport.onKeyPress(.return) { modifiers in
|
||||
if modifiers.contains(.shift) {
|
||||
_ = surfaceView.navigateSearchToPrevious()
|
||||
} else {
|
||||
_ = surfaceView.navigateSearchToNext()
|
||||
return .handled
|
||||
}
|
||||
return .handled
|
||||
return .ignored
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
|
|
@ -611,8 +630,13 @@ extension Ghostty {
|
|||
/// Explicit font size to use in points
|
||||
var fontSize: Float32?
|
||||
|
||||
/// Explicit working directory to set
|
||||
var workingDirectory: String?
|
||||
/// Explicit working directory. This is normalized on assignment to
|
||||
/// remove any redundant and trailing path separators.
|
||||
var workingDirectory: String? {
|
||||
get { normalizedWorkingDirectory }
|
||||
set { normalizedWorkingDirectory = newValue.map { FilePath($0).string } }
|
||||
}
|
||||
private var normalizedWorkingDirectory: String?
|
||||
|
||||
/// Explicit command to set
|
||||
var command: String?
|
||||
|
|
|
|||
|
|
@ -89,6 +89,12 @@ extension Ghostty {
|
|||
// Whether the cursor is currently visible (not hidden by typing, etc.)
|
||||
@Published private(set) var cursorVisible: Bool = true
|
||||
|
||||
/// Whether the belonging window is visible
|
||||
///
|
||||
/// We track this to restore surface occlusion state
|
||||
/// after this surface is dragged to another window
|
||||
var isWindowVisible = false
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
@Published private(set) var derivedConfig: DerivedConfig
|
||||
|
||||
|
|
@ -719,7 +725,18 @@ extension Ghostty {
|
|||
|
||||
// Update our derived config
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.derivedConfig = DerivedConfig(config)
|
||||
guard let self else { return }
|
||||
self.derivedConfig = DerivedConfig(config)
|
||||
|
||||
// If the cached OSC 11 background color disagrees with the new
|
||||
// config-derived background, drop it so window chrome follows
|
||||
// the new config (e.g., on light/dark theme auto-switch). The
|
||||
// cached value is restored next time the terminal emits a
|
||||
// color_change.
|
||||
if let cached = self.backgroundColor,
|
||||
cached != self.derivedConfig.backgroundColor {
|
||||
self.backgroundColor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1582,7 +1599,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "copy_to_clipboard"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1590,7 +1607,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "paste_from_clipboard"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1598,7 +1615,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "paste_from_clipboard"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1606,7 +1623,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "paste_from_selection"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1614,7 +1631,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "select_all"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1622,7 +1639,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "start_search"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1630,7 +1647,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "search_selection"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1638,7 +1655,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "scroll_to_selection"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1654,7 +1671,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "end_search"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1662,7 +1679,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "toggle_readonly"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1690,7 +1707,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "reset"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1698,7 +1715,7 @@ extension Ghostty {
|
|||
guard let surface = self.surface else { return }
|
||||
let action = "inspector:toggle"
|
||||
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1730,7 +1747,7 @@ extension Ghostty {
|
|||
// so we need @MainActor since we're reading/writing view state.
|
||||
UNUserNotificationCenter.current().add(request) { @MainActor error in
|
||||
if let error = error {
|
||||
AppDelegate.logger.error("Error scheduling user notification: \(error)")
|
||||
AppDelegate.logger.error("Error scheduling user notification: \(error, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,3 +131,49 @@ enum BackportNSGlassStyle {
|
|||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Backported `TextField` that supports text selection on macOS 15/iOS 18 and up. The `selection`
|
||||
/// has no effect on versions below macOS 15/iOS 18.
|
||||
struct BackportSelectionTextField: View {
|
||||
private let titleKey: LocalizedStringKey
|
||||
@Binding private var text: String
|
||||
@Binding private var textSelection: Range<String.Index>?
|
||||
|
||||
init(
|
||||
_ titleKey: LocalizedStringKey,
|
||||
text: Binding<String>,
|
||||
selection: Binding<Range<String.Index>?>
|
||||
) {
|
||||
self.titleKey = titleKey
|
||||
self._text = text
|
||||
self._textSelection = selection
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 18.0, macOS 15, *) {
|
||||
TextField(
|
||||
titleKey,
|
||||
text: _text,
|
||||
selection: Binding(
|
||||
get: {
|
||||
if let textSelection {
|
||||
TextSelection(range: textSelection)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
},
|
||||
set: { selection in
|
||||
if let selection,
|
||||
case .selection(let range) = selection.indices {
|
||||
self.textSelection = range
|
||||
} else {
|
||||
self.textSelection = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
TextField(titleKey, text: _text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ typealias OSView = NSView
|
|||
typealias OSColor = NSColor
|
||||
typealias OSSize = NSSize
|
||||
typealias OSPasteboard = NSPasteboard
|
||||
typealias OSApplication = NSApplication
|
||||
|
||||
protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType {
|
||||
associatedtype OSViewType: NSView
|
||||
|
|
@ -36,6 +37,7 @@ typealias OSView = UIView
|
|||
typealias OSColor = UIColor
|
||||
typealias OSSize = CGSize
|
||||
typealias OSPasteboard = UIPasteboard
|
||||
typealias OSApplication = UIApplication
|
||||
|
||||
protocol OSViewRepresentable: UIViewRepresentable {
|
||||
associatedtype OSViewType: UIView
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ extension NSWindow {
|
|||
var error: NSError?
|
||||
let success = GhosttyAddTabbedWindowSafely(self, child, ordered.rawValue, &error)
|
||||
if let error {
|
||||
Ghostty.logger.error("addTabbedWindow failed: \(error.localizedDescription)")
|
||||
Ghostty.logger.error("addTabbedWindow failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
return success
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
#if canImport(AppKit)
|
||||
|
||||
/// Normalizes the interface between NSPasteboard and UIPasteboard for working with pasteboard
|
||||
/// strings.
|
||||
extension OSPasteboard {
|
||||
@MainActor static let find = OSPasteboard(name: .find)
|
||||
|
||||
/// The pasteboard's current string value.
|
||||
@MainActor var string: String? {
|
||||
get {
|
||||
string(forType: .string)
|
||||
}
|
||||
set {
|
||||
clearContents()
|
||||
if let newValue {
|
||||
setString(newValue, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#elseif canImport(UIKit)
|
||||
|
||||
extension OSPasteboard {
|
||||
static let find = OSPasteboard.withUniqueName()
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import AppKit
|
||||
import GhosttyKit
|
||||
import Testing
|
||||
@testable import Ghostty
|
||||
|
||||
@MainActor struct SurfaceView_SearchStateTests {
|
||||
typealias SearchState = Ghostty.OSSurfaceView.SearchState
|
||||
typealias StartSearch = Ghostty.Action.StartSearch
|
||||
|
||||
/// A unique pasteboard for each test case prevents flakiness.
|
||||
let pasteboard = OSPasteboard.withUniqueName()
|
||||
|
||||
init() {
|
||||
pasteboard.setString("pb", forType: .string)
|
||||
}
|
||||
|
||||
@Test func init_withNilNeedle_readsPasteboardNeedle() {
|
||||
let sut = SearchState(
|
||||
from: StartSearch(c: .init(needle: nil)),
|
||||
pasteboard: pasteboard
|
||||
)
|
||||
#expect(sut.needle == "pb")
|
||||
}
|
||||
|
||||
@Test func init_withEmptyNeedle_readsPasteboardNeedle() {
|
||||
"".withCString { needle in
|
||||
let sut = SearchState(
|
||||
from: StartSearch(c: .init(needle: needle)),
|
||||
pasteboard: pasteboard
|
||||
)
|
||||
#expect(sut.needle == "pb")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func init_withNeedle_setsNeedle() {
|
||||
"start".withCString { needle in
|
||||
let sut = SearchState(
|
||||
from: StartSearch(c: .init(needle: needle)),
|
||||
pasteboard: pasteboard
|
||||
)
|
||||
#expect(sut.needle == "start")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func init_withNeedle_writesPasteboard() {
|
||||
"start".withCString { needle in
|
||||
_ = SearchState(
|
||||
from: StartSearch(c: .init(needle: needle)),
|
||||
pasteboard: pasteboard
|
||||
)
|
||||
#expect(pasteboard.string(forType: .string) == "start")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func writePasteboardNeedle_writesPasteboard() {
|
||||
let sut = SearchState(
|
||||
from: StartSearch(c: .init(needle: nil)),
|
||||
pasteboard: pasteboard
|
||||
)
|
||||
sut.needle = "sut"
|
||||
sut.writePasteboardNeedle()
|
||||
#expect(pasteboard.string(forType: .string) == "sut")
|
||||
}
|
||||
|
||||
@Test func readPasteboardNeedle_whenPasteboardNeedleIsNil() {
|
||||
let sut = SearchState(
|
||||
from: StartSearch(c: .init(needle: nil)),
|
||||
pasteboard: pasteboard
|
||||
)
|
||||
pasteboard.clearContents()
|
||||
sut.needle = "sut"
|
||||
sut.readPasteboardNeedle()
|
||||
#expect(sut.needle == "sut")
|
||||
}
|
||||
|
||||
@Test func readPasteboardNeedle_whenPasteboardNeedleIsValid() {
|
||||
let sut = SearchState(
|
||||
from: StartSearch(c: .init(needle: nil)),
|
||||
pasteboard: pasteboard
|
||||
)
|
||||
sut.needle = "sut"
|
||||
sut.readPasteboardNeedle()
|
||||
#expect(sut.needle == "pb")
|
||||
}
|
||||
|
||||
@Test func readPasteboardNeedle_setsNeedleSelectionRange() {
|
||||
let sut = SearchState(
|
||||
from: StartSearch(c: .init(needle: nil)),
|
||||
pasteboard: pasteboard
|
||||
)
|
||||
sut.needle = "sut"
|
||||
sut.readPasteboardNeedle()
|
||||
|
||||
let expected = "pb".startIndex..<"pb".endIndex
|
||||
#expect(sut.needleSelection == expected)
|
||||
}
|
||||
}
|
||||
109
nix/tests.nix
109
nix/tests.nix
|
|
@ -281,4 +281,113 @@ in {
|
|||
server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30)
|
||||
'';
|
||||
};
|
||||
|
||||
# Regression test for the GTK audio-bell GStreamer thread leak. Each audio
|
||||
# bell used to allocate a fresh gtk.MediaFile (and thus a GStreamer pipeline
|
||||
# whose GL sink spawns gstglcontext/gldisplay-event threads that are never
|
||||
# joined), leaking ~4 threads per ring; the fix reuses one MediaFile per
|
||||
# surface. This rings many bells and asserts the GUI process thread count
|
||||
# stays bounded. Runs under GNOME on Wayland so it exercises the real path.
|
||||
bell-leak-check-gnome = mkTestGnome {
|
||||
name = "bell-leak-check-gnome";
|
||||
settings = {
|
||||
# The VM has no GPU, so GNOME and Ghostty render via llvmpipe. Give the
|
||||
# guest enough cores/RAM that software GL can bring up Ghostty's window
|
||||
# before the +new-window D-Bus activation times out, and force clean
|
||||
# software GL so mesa doesn't stall probing for absent hardware.
|
||||
virtualisation.cores = 4;
|
||||
virtualisation.memorySize = 4096;
|
||||
environment.sessionVariables = {
|
||||
LIBGL_ALWAYS_SOFTWARE = "1";
|
||||
GALLIUM_DRIVER = "llvmpipe";
|
||||
};
|
||||
|
||||
home-manager.users.ghostty = {
|
||||
xdg.configFile = {
|
||||
"ghostty/config".text = ''
|
||||
bell-features = audio
|
||||
bell-audio-path = ${pkgs.sound-theme-freedesktop}/share/sounds/freedesktop/stereo/bell.oga
|
||||
bell-audio-volume = 0
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
testScript = {nodes, ...}: let
|
||||
user = nodes.machine.users.users.ghostty;
|
||||
bus_path = "/run/user/${toString user.uid}/bus";
|
||||
bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}";
|
||||
gdbus = "${bus} gdbus";
|
||||
ghostty = "${bus} ghostty";
|
||||
su = command: "su - ${user.name} -c '${command}'";
|
||||
gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval";
|
||||
wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class";
|
||||
|
||||
# Emits N BELs >100ms apart (which clears the bell rate-limit), then holds
|
||||
# so the window (and its audio pipeline) stays alive while we sample. Run
|
||||
# by typing its path into the open window; written as a script to avoid
|
||||
# shell-escaping the BEL byte through the test driver.
|
||||
ringBells = pkgs.writeShellScript "ring-bells" ''
|
||||
for _ in $(seq 100); do printf '\a'; sleep 0.12; done
|
||||
sleep 60
|
||||
'';
|
||||
in ''
|
||||
# Thread count of the ghostty GUI process: the ghostty process with the
|
||||
# most threads. The CLI also spawns 1-thread launcher/helper stubs (and
|
||||
# this very command matches the pgrep), but those are filtered by the max.
|
||||
def ghostty_threads():
|
||||
out = machine.succeed(
|
||||
"max=0; "
|
||||
"for p in $(pgrep -f ghostty); do "
|
||||
" n=$(ls /proc/$p/task 2>/dev/null | wc -l); "
|
||||
" [ \"$n\" -gt \"$max\" ] && max=$n; "
|
||||
"done; "
|
||||
"echo $max"
|
||||
).strip()
|
||||
return int(out)
|
||||
|
||||
def window_open():
|
||||
status, _ = machine.execute("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'")
|
||||
return status == 0
|
||||
|
||||
with subtest("boot and open a keep-alive ghostty window"):
|
||||
start_all()
|
||||
machine.wait_for_x()
|
||||
machine.wait_for_file("${bus_path}")
|
||||
machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}")
|
||||
|
||||
# Under software GL the +new-window D-Bus activation can exceed its
|
||||
# client-side timeout even though the window still comes up, so we
|
||||
# tolerate a failed call and (re)nudge until the window appears.
|
||||
for _ in range(6):
|
||||
machine.execute("${su "${ghostty} +new-window"}")
|
||||
if window_open():
|
||||
break
|
||||
machine.sleep(5)
|
||||
assert window_open(), "ghostty window never appeared"
|
||||
machine.sleep(2)
|
||||
|
||||
with subtest("ring 100 bells and assert the thread count stays bounded"):
|
||||
baseline = ghostty_threads()
|
||||
|
||||
# Ring the bells by running the script inside the focused window (type
|
||||
# its path + Enter). A separate `ghostty -e` process can't open the
|
||||
# display from the bare su environment, so we drive the open window.
|
||||
machine.send_chars("${ringBells}\n")
|
||||
|
||||
# 100 bells * 0.12s + settle, within the script's trailing hold so the
|
||||
# window (and its audio pipeline) is still alive when we sample.
|
||||
machine.sleep(22)
|
||||
final = ghostty_threads()
|
||||
|
||||
growth = final - baseline
|
||||
print(f"bell-leak: baseline={baseline} final={final} growth={growth}")
|
||||
|
||||
# Pre-fix grows ~4 threads/bell (~+400 over 100 bells); the fix adds
|
||||
# only one pipeline's worth of threads. 40 sits well clear of both.
|
||||
assert growth <= 40, (
|
||||
f"thread count grew by {growth} over 100 bells "
|
||||
f"(baseline={baseline}, final={final}): audio-bell pipeline leak regressed"
|
||||
)
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ pub fn build(b: *std.Build) !void {
|
|||
try android_ndk.addPaths(b, lib);
|
||||
}
|
||||
|
||||
// Mainly for iOS simulators, but we add for all Darwin target for
|
||||
// consistency.
|
||||
if (target.result.os.tag.isDarwin()) {
|
||||
const apple_sdk = @import("apple_sdk");
|
||||
try apple_sdk.addPaths(b, lib);
|
||||
}
|
||||
|
||||
var flags: std.ArrayList([]const u8) = .empty;
|
||||
defer flags.deinit(b.allocator);
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
|
|
|
|||
|
|
@ -12,5 +12,6 @@
|
|||
},
|
||||
|
||||
.android_ndk = .{ .path = "../android-ndk" },
|
||||
.apple_sdk = .{ .path = "../apple-sdk" },
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,353 @@
|
|||
# Basque translations for com.mitchellh.ghostty package.
|
||||
# Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors"
|
||||
# This file is distributed under the same license as the com.mitchellh.ghostty package.
|
||||
# Mikel Larreategi <mlarreategi@codesyntax.com>, 2026.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: com.mitchellh.ghostty\n"
|
||||
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
|
||||
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
|
||||
"PO-Revision-Date: 2026-05-01 12:56+0200\n"
|
||||
"Last-Translator: Mikel Larreategi <mlarreategi@codesyntax.com>\n"
|
||||
"Language-Team: Language eu\n"
|
||||
"Language: eu\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: dist/linux/ghostty_nautilus.py:53
|
||||
msgid "Open in Ghostty"
|
||||
msgstr "Ireki Ghostty-n"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:201
|
||||
msgid "Authorize Clipboard Access"
|
||||
msgstr "Baimendu arbelera sartzea"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:17
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:17
|
||||
msgid "Deny"
|
||||
msgstr "Ukatu"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:18
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:18
|
||||
msgid "Allow"
|
||||
msgstr "Baimendu"
|
||||
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92
|
||||
msgid "Remember choice for this split"
|
||||
msgstr "Gogoratu zatiketa-aukera hau"
|
||||
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93
|
||||
msgid "Reload configuration to show this prompt again"
|
||||
msgstr "Birkargatu konfigurazioa eta erakutsi hau berriz"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7
|
||||
#: src/apprt/gtk/ui/1.5/title-dialog.blp:8
|
||||
msgid "Cancel"
|
||||
msgstr "Utzi"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8
|
||||
#: src/apprt/gtk/ui/1.2/search-overlay.blp:85
|
||||
#: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17
|
||||
msgid "Close"
|
||||
msgstr "Itxi"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6
|
||||
msgid "Configuration Errors"
|
||||
msgstr "Konfigurazio erroreak"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7
|
||||
msgid ""
|
||||
"One or more configuration errors were found. Please review the errors below, "
|
||||
"and either reload your configuration or ignore these errors."
|
||||
msgstr ""
|
||||
"Konfigurazio erroreren bat aurkitu da. Begiratu erroreak azpian, eta "
|
||||
"birkargatu konfigurazioa edo baztertu erroreak."
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10
|
||||
msgid "Ignore"
|
||||
msgstr "Baztertu"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:366 src/apprt/gtk/ui/1.5/window.blp:300
|
||||
msgid "Reload Configuration"
|
||||
msgstr "Birkargatu konfigurazioa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/debug-warning.blp:7
|
||||
#: src/apprt/gtk/ui/1.3/debug-warning.blp:6
|
||||
msgid ""
|
||||
"⚠️ You're running a debug build of Ghostty! Performance will be degraded."
|
||||
msgstr ""
|
||||
"⚠️ Garapeneko Ghostty bertsio bat erabiltzen ari zara! Errendimendua ez da "
|
||||
"ona izango"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/inspector-window.blp:5
|
||||
msgid "Ghostty: Terminal Inspector"
|
||||
msgstr "Ghostty: terminal ikuskatzailea"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/search-overlay.blp:29
|
||||
msgid "Find…"
|
||||
msgstr "Bilatu…"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/search-overlay.blp:64
|
||||
msgid "Previous Match"
|
||||
msgstr "Aurreko bilaketa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/search-overlay.blp:74
|
||||
msgid "Next Match"
|
||||
msgstr "Hurrengo bilaketa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:6
|
||||
msgid "Oh, no."
|
||||
msgstr "Oh, ez!"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:7
|
||||
msgid "Unable to acquire an OpenGL context for rendering."
|
||||
msgstr "Ezin izan da OpenGL kontestua erabili."
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:97
|
||||
msgid ""
|
||||
"This terminal is in read-only mode. You can still view, select, and scroll "
|
||||
"through the content, but no input events will be sent to the running "
|
||||
"application."
|
||||
msgstr ""
|
||||
"Terminal hau irakurtzeko moduan dago. Ikusi, aukeratu eta kontestuan zehar "
|
||||
"gora eta behera ibili zaitezke, baina ez da sarrera ekintzarik bidaliko."
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:107
|
||||
msgid "Read-only"
|
||||
msgstr "Irakurtzeko-bakarrik"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200
|
||||
msgid "Copy"
|
||||
msgstr "Kopiatu"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:205
|
||||
msgid "Paste"
|
||||
msgstr "Itsatsi"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:270
|
||||
msgid "Notify on Next Command Finish"
|
||||
msgstr "Jakinarazi hurrengo komandoa amaitzean"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273
|
||||
msgid "Clear"
|
||||
msgstr "Garbitu"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:278
|
||||
msgid "Reset"
|
||||
msgstr "Berrezarri"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:242
|
||||
msgid "Split"
|
||||
msgstr "Zatitu"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245
|
||||
msgid "Change Title…"
|
||||
msgstr "Aldatu izenburua…"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:177
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:250
|
||||
msgid "Split Up"
|
||||
msgstr "Zatitu gorantz"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:182
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:255
|
||||
msgid "Split Down"
|
||||
msgstr "Zatitu beherantz"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:187
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:260
|
||||
msgid "Split Left"
|
||||
msgstr "Zatitu ezkerrera"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:192
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:265
|
||||
msgid "Split Right"
|
||||
msgstr "Zatitu eskumara"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:322
|
||||
msgid "Tab"
|
||||
msgstr "Fitxa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:320
|
||||
msgid "Change Tab Title…"
|
||||
msgstr "Aldatu fitxaren izenburua…"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229
|
||||
msgid "New Tab"
|
||||
msgstr "Fitxa berria"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:335 src/apprt/gtk/ui/1.5/window.blp:234
|
||||
msgid "Close Tab"
|
||||
msgstr "Itxi fitxa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:342
|
||||
msgid "Window"
|
||||
msgstr "Leihoa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:212
|
||||
msgid "New Window"
|
||||
msgstr "Leiho berria"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:350 src/apprt/gtk/ui/1.5/window.blp:217
|
||||
msgid "Close Window"
|
||||
msgstr "Itxi leihoa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:358
|
||||
msgid "Config"
|
||||
msgstr "Konfigurazioa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:361 src/apprt/gtk/ui/1.5/window.blp:295
|
||||
msgid "Open Configuration"
|
||||
msgstr "Ireki konfigurazioa"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/title-dialog.blp:5
|
||||
msgid "Leave blank to restore the default title."
|
||||
msgstr "Utzi hutsik defektuzko izenburua berrezartzeko"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/title-dialog.blp:9
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108
|
||||
msgid "New Split"
|
||||
msgstr "Zatitu"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126
|
||||
msgid "View Open Tabs"
|
||||
msgstr "Ikusi irekitako fitxak"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140
|
||||
msgid "Main Menu"
|
||||
msgstr "Menu nagusia"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:285
|
||||
msgid "Command Palette"
|
||||
msgstr "Komando paleta"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:290
|
||||
msgid "Terminal Inspector"
|
||||
msgstr "Terminal ikuskatzailea"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1727
|
||||
msgid "About Ghostty"
|
||||
msgstr "Ghosttyri buruz"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:312
|
||||
msgid "Quit"
|
||||
msgstr "Irten"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/command-palette.blp:17
|
||||
msgid "Execute a command…"
|
||||
msgstr "Exekutatu komando bat…"
|
||||
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198
|
||||
msgid ""
|
||||
"An application is attempting to write to the clipboard. The current "
|
||||
"clipboard contents are shown below."
|
||||
msgstr ""
|
||||
"Aplikazio bat arbelean idatzi nahian dabil. Hau da arbelaren uneko "
|
||||
"edukia."
|
||||
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202
|
||||
msgid ""
|
||||
"An application is attempting to read from the clipboard. The current "
|
||||
"clipboard contents are shown below."
|
||||
msgstr ""
|
||||
"Aplikazio bat arbeletik irakurri nahian dabil. Hau da arbelaren gaur egungo "
|
||||
"edukia."
|
||||
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205
|
||||
msgid "Warning: Potentially Unsafe Paste"
|
||||
msgstr "Oharra: hau itsastea ez da segurua"
|
||||
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:206
|
||||
msgid ""
|
||||
"Pasting this text into the terminal may be dangerous as it looks like some "
|
||||
"commands may be executed."
|
||||
msgstr ""
|
||||
"Testu hau terminalean itsastea arriskutsua izan daiteke komandoren batzuk "
|
||||
"exekutatzea ekar dezakeelako."
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:184
|
||||
msgid "Quit Ghostty?"
|
||||
msgstr "Ghostty itxi?"
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:185
|
||||
msgid "Close Tab?"
|
||||
msgstr "Fitxa itxi?"
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:186
|
||||
msgid "Close Window?"
|
||||
msgstr "Leihoa itxi?"
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:187
|
||||
msgid "Close Split?"
|
||||
msgstr "Zatikatzea itxi?"
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:193
|
||||
msgid "All terminal sessions will be terminated."
|
||||
msgstr "Terminal saio guztiak itxi egingo dira."
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:194
|
||||
msgid "All terminal sessions in this tab will be terminated."
|
||||
msgstr "Fitxa honetako terminal saioa itxi egingo da."
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:195
|
||||
msgid "All terminal sessions in this window will be terminated."
|
||||
msgstr "Leiho honetako terminal saio guztiak itxi egingo dira."
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:196
|
||||
msgid "The currently running process in this split will be terminated."
|
||||
msgstr "Zatikatze honetan martxan dagoen prozesua itxi egingo da."
|
||||
|
||||
#: src/apprt/gtk/class/surface.zig:1108
|
||||
msgid "Command Finished"
|
||||
msgstr "Komandoa amaitu da"
|
||||
|
||||
#: src/apprt/gtk/class/surface.zig:1109
|
||||
msgid "Command Succeeded"
|
||||
msgstr "Komandoa ondo amaitu da"
|
||||
|
||||
#: src/apprt/gtk/class/surface.zig:1110
|
||||
msgid "Command Failed"
|
||||
msgstr "Komandoak huts egin du"
|
||||
|
||||
#: src/apprt/gtk/class/surface_child_exited.zig:109
|
||||
msgid "Command succeeded"
|
||||
msgstr "Komandoa ondo amaitu da"
|
||||
|
||||
#: src/apprt/gtk/class/surface_child_exited.zig:113
|
||||
msgid "Command failed"
|
||||
msgstr "Komandoak huts egin du"
|
||||
|
||||
#: src/apprt/gtk/class/title_dialog.zig:225
|
||||
msgid "Change Terminal Title"
|
||||
msgstr "Aldatu terminalaren izenburua"
|
||||
|
||||
#: src/apprt/gtk/class/title_dialog.zig:226
|
||||
msgid "Change Tab Title"
|
||||
msgstr "Aldatu fitxaren izenburua"
|
||||
|
||||
#: src/apprt/gtk/class/window.zig:1007
|
||||
msgid "Reloaded the configuration"
|
||||
msgstr "Konfigurazioa berriz kargatu da"
|
||||
|
||||
#: src/apprt/gtk/class/window.zig:1566
|
||||
msgid "Copied to clipboard"
|
||||
msgstr "Arbelera kopiatu da"
|
||||
|
||||
#: src/apprt/gtk/class/window.zig:1568
|
||||
msgid "Cleared clipboard"
|
||||
msgstr "Arbela garbitu da"
|
||||
|
||||
#: src/apprt/gtk/class/window.zig:1708
|
||||
msgid "Ghostty Developers"
|
||||
msgstr "Ghostty garatzaileak"
|
||||
|
|
@ -50,6 +50,7 @@ export XLOCALEDIR="${SNAP}/usr/share/X11/locale"
|
|||
export GTK_PATH="$SNAP/usr/lib/$ARCH/gtk-4.0"
|
||||
export GIO_MODULE_DIR="$SNAP/usr/lib/$ARCH/gio/modules"
|
||||
unset GIO_EXTRA_MODULES
|
||||
export TERMINFO_DIRS="${SNAP}/share/terminfo${TERMINFO_DIRS:+:$TERMINFO_DIRS}"
|
||||
|
||||
# Gdk-pixbuf loaders
|
||||
mkdir -p "$SNAP_USER_COMMON/.cache"
|
||||
|
|
|
|||
1051
src/Surface.zig
1051
src/Surface.zig
File diff suppressed because it is too large
Load Diff
|
|
@ -336,6 +336,7 @@ pub const App = struct {
|
|||
) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
|
||||
switch (action) {
|
||||
.new_window => return false,
|
||||
.toggle_quick_terminal => return false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const CoreApp = @import("../../App.zig");
|
|||
const Application = @import("class/application.zig").Application;
|
||||
const Surface = @import("Surface.zig");
|
||||
const ipcNewWindow = @import("ipc/new_window.zig").newWindow;
|
||||
const ipcToggleQuickTerminal = @import("ipc/toggle_quick_terminal.zig").toggleQuickTerminal;
|
||||
|
||||
const log = std.log.scoped(.gtk);
|
||||
|
||||
|
|
@ -84,6 +85,7 @@ pub fn performIpc(
|
|||
) !bool {
|
||||
switch (action) {
|
||||
.new_window => return try ipcNewWindow(alloc, target, value),
|
||||
.toggle_quick_terminal => return try ipcToggleQuickTerminal(alloc, target),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1419,6 +1419,7 @@ pub const Application = extern struct {
|
|||
.init("present-surface", actionPresentSurface, t_variant_type),
|
||||
.init("quit", actionQuit, null),
|
||||
.init("reload-config", actionReloadConfig, null),
|
||||
.init("toggle-quick-terminal", actionToggleQuickTerminal, null),
|
||||
};
|
||||
|
||||
ext.actions.add(Self, self, &actions);
|
||||
|
|
@ -1669,6 +1670,17 @@ pub const Application = extern struct {
|
|||
};
|
||||
}
|
||||
|
||||
fn actionToggleQuickTerminal(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
const priv = self.private();
|
||||
priv.core_app.performAction(self.rt(), .toggle_quick_terminal) catch |err| {
|
||||
log.warn("error toggling quick terminal err={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
fn actionQuit(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,13 @@ pub const SplitTree = extern struct {
|
|||
/// used to debounce updates.
|
||||
rebuild_source: ?c_uint = null,
|
||||
|
||||
/// The source that we use to restore focus. With enough nested
|
||||
/// splits, some surfaces might initially be allocated a width or
|
||||
/// height of 0 which causes them to get unmapped and lose focus.
|
||||
/// We can reliably restore focus to the last focused surface only
|
||||
/// once it is mapped again.
|
||||
restore_focus_source: ?c_uint = null,
|
||||
|
||||
/// Used to store state about a pending surface close for the
|
||||
/// close dialog.
|
||||
pending_close: ?Surface.Tree.Node.Handle,
|
||||
|
|
@ -415,6 +422,13 @@ pub const SplitTree = extern struct {
|
|||
self,
|
||||
.{ .detail = "focused" },
|
||||
);
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
surface,
|
||||
*Self,
|
||||
propSurfaceMapped,
|
||||
self,
|
||||
.{ .detail = "mapped" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -571,6 +585,12 @@ pub const SplitTree = extern struct {
|
|||
}
|
||||
priv.rebuild_source = null;
|
||||
}
|
||||
if (priv.restore_focus_source) |v| {
|
||||
if (glib.Source.remove(v) == 0) {
|
||||
log.warn("unable to remove restore_focus source", .{});
|
||||
}
|
||||
priv.restore_focus_source = null;
|
||||
}
|
||||
|
||||
gtk.Widget.disposeTemplate(
|
||||
self.as(gtk.Widget),
|
||||
|
|
@ -766,6 +786,24 @@ pub const SplitTree = extern struct {
|
|||
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
|
||||
}
|
||||
|
||||
fn propSurfaceMapped(
|
||||
surface: *Surface,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
if (!surface.getMapped()) return;
|
||||
|
||||
// We could add the idle callback only if this is actually the last
|
||||
// focused surface. But we can avoid that check because usually all
|
||||
// the surfaces get mapped at once, so the idle callback will run
|
||||
// only once anyway.
|
||||
const priv = self.private();
|
||||
if (priv.restore_focus_source == null) priv.restore_focus_source = glib.idleAdd(
|
||||
onRestoreFocus,
|
||||
self,
|
||||
);
|
||||
}
|
||||
|
||||
fn propTree(
|
||||
self: *Self,
|
||||
_: *gobject.ParamSpec,
|
||||
|
|
@ -779,14 +817,20 @@ pub const SplitTree = extern struct {
|
|||
self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec);
|
||||
self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec);
|
||||
|
||||
// If we were planning a rebuild, always remove that so we can
|
||||
// start from a clean slate.
|
||||
// If we were planning a rebuild or focus restore, always remove
|
||||
// that so we can start from a clean slate.
|
||||
if (priv.rebuild_source) |v| {
|
||||
if (glib.Source.remove(v) == 0) {
|
||||
log.warn("unable to remove rebuild source", .{});
|
||||
}
|
||||
priv.rebuild_source = null;
|
||||
}
|
||||
if (priv.restore_focus_source) |v| {
|
||||
if (glib.Source.remove(v) == 0) {
|
||||
log.warn("unable to remove restore_focus source", .{});
|
||||
}
|
||||
priv.restore_focus_source = null;
|
||||
}
|
||||
|
||||
// If we transitioned to an empty tree, clear immediately instead of
|
||||
// waiting for an idle callback. Delaying teardown can keep the last
|
||||
|
|
@ -842,6 +886,26 @@ pub const SplitTree = extern struct {
|
|||
return 0;
|
||||
}
|
||||
|
||||
fn onRestoreFocus(ud: ?*anyopaque) callconv(.c) c_int {
|
||||
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
|
||||
|
||||
// Always mark our source as null since we're done.
|
||||
const priv = self.private();
|
||||
priv.restore_focus_source = null;
|
||||
|
||||
// If we have a last-focused surface and it is mapped, restore focus
|
||||
// to it. Depending on the available size, the surface might already
|
||||
// have focus because it never got unmapped. In that case grabbing
|
||||
// focus will have no effect.
|
||||
if (priv.last_focused.get()) |v| {
|
||||
defer v.unref();
|
||||
if (v.getMapped()) {
|
||||
v.grabFocus();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Builds the widget tree associated with a surface split tree.
|
||||
///
|
||||
/// Returned widgets are expected to be attached to a parent by the caller.
|
||||
|
|
@ -1044,6 +1108,12 @@ const SplitTreeSplit = extern struct {
|
|||
/// Source to handle repositioning the split when properties change.
|
||||
idle: ?c_uint = null,
|
||||
|
||||
/// Whether the max-position/position property of the gtk.Paned widget
|
||||
/// changed. We use these to distinguish between a resize and the user
|
||||
/// manually moving the split divider. See the "on-idle" function.
|
||||
max_changed: bool = false,
|
||||
pos_changed: bool = false,
|
||||
|
||||
// Template bindings
|
||||
paned: *gtk.Paned,
|
||||
|
||||
|
|
@ -1083,21 +1153,37 @@ const SplitTreeSplit = extern struct {
|
|||
gtk.Widget.initTemplate(self.as(gtk.Widget));
|
||||
}
|
||||
|
||||
fn refresh(self: *Self) void {
|
||||
const priv = self.private();
|
||||
if (priv.idle == null) priv.idle = glib.idleAdd(
|
||||
onIdle,
|
||||
self,
|
||||
);
|
||||
}
|
||||
|
||||
// We need to keep the split ratios from the tree datastructure and
|
||||
// widget tree in sync. Using the max-position and position properties
|
||||
// of the gtk.Paned widget, we can distinguish a resize from a manual
|
||||
// update (e.g. the user dragging the divider).If max-position changes,
|
||||
// we always have a widget resize. Usually position will change as well
|
||||
// but it might not if the size change is small enough. If only position
|
||||
// changes, we have a manual human update.
|
||||
//
|
||||
// This is a hack, it relies on the timing of property notifcations.
|
||||
// From looking at the GTK source code, it should not be possible that
|
||||
// we interpret a position change from a resize as a manual update.
|
||||
// When a gtk.Paned is resized, internally the gtk_paned_calc_position
|
||||
// function will change both max-position and position and synchronously
|
||||
// call our propMaxPosition and propPosition functions. I.e. when the
|
||||
// widget is resized, it should not be possible for onIdle to run before
|
||||
// we have been notified of both property changes.
|
||||
fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
|
||||
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
|
||||
const priv = self.private();
|
||||
const paned = priv.paned;
|
||||
|
||||
// Our idle source is always over
|
||||
priv.idle = null;
|
||||
// Clear source and fields at the end. Otherwise if setPosition is
|
||||
// called below, propPosition is triggered and would add another
|
||||
// idle callback before this one is finished.
|
||||
defer priv.idle = null;
|
||||
defer priv.max_changed = false;
|
||||
defer priv.pos_changed = false;
|
||||
|
||||
if (!priv.max_changed and !priv.pos_changed) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get our split. This is the most dangerous part of this entire
|
||||
// widget. We assume that this widget is always a child of a
|
||||
|
|
@ -1132,16 +1218,6 @@ const SplitTreeSplit = extern struct {
|
|||
);
|
||||
break :max gobject.ext.Value.get(&val, c_int);
|
||||
};
|
||||
const pos_set: bool = max: {
|
||||
var val = gobject.ext.Value.new(c_int);
|
||||
defer val.unset();
|
||||
gobject.Object.getProperty(
|
||||
paned.as(gobject.Object),
|
||||
"position-set",
|
||||
&val,
|
||||
);
|
||||
break :max gobject.ext.Value.get(&val, c_int) != 0;
|
||||
};
|
||||
|
||||
// We don't actually use min, but we don't expect this to ever
|
||||
// be non-zero, so let's add an assert to ensure that.
|
||||
|
|
@ -1172,51 +1248,51 @@ const SplitTreeSplit = extern struct {
|
|||
return 0;
|
||||
}
|
||||
|
||||
// If we're out of bounds, then we need to either set the position
|
||||
// to what we expect OR update our expected ratio.
|
||||
|
||||
// If we've never set the position, then we set it to the desired.
|
||||
if (!pos_set) {
|
||||
if (priv.max_changed) {
|
||||
// Widget got resized, update position to match desired ratio.
|
||||
// Note that if max-position is small, it might not be possible
|
||||
// to accurately set the desired ratio. E.g. with max-position=2
|
||||
// you can only have ratios 0, 0.5 and 1.
|
||||
const desired_pos: c_int = desired_pos: {
|
||||
const max_f64: f64 = @floatFromInt(max);
|
||||
break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio));
|
||||
};
|
||||
paned.setPosition(desired_pos);
|
||||
return 0;
|
||||
} else {
|
||||
// If only position changed, this is a manual human update and
|
||||
// we need to write our update back to the tree.
|
||||
tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
|
||||
}
|
||||
|
||||
// If we've set the position, then this is a manual human update
|
||||
// and we need to write our update back to the tree.
|
||||
tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
// Signal handlers
|
||||
|
||||
fn propPosition(
|
||||
_: *gtk.Paned,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
fn propMaxPosition(
|
||||
_: *gtk.Paned,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.refresh();
|
||||
const priv = self.private();
|
||||
priv.max_changed = true;
|
||||
if (priv.idle == null) priv.idle = glib.idleAdd(
|
||||
onIdle,
|
||||
self,
|
||||
);
|
||||
}
|
||||
|
||||
fn propMinPosition(
|
||||
fn propPosition(
|
||||
_: *gtk.Paned,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.refresh();
|
||||
const priv = self.private();
|
||||
priv.pos_changed = true;
|
||||
if (priv.idle == null) priv.idle = glib.idleAdd(
|
||||
onIdle,
|
||||
self,
|
||||
);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
|
|
@ -1275,7 +1351,6 @@ const SplitTreeSplit = extern struct {
|
|||
|
||||
// Template Callbacks
|
||||
class.bindTemplateCallback("notify_max_position", &propMaxPosition);
|
||||
class.bindTemplateCallback("notify_min_position", &propMinPosition);
|
||||
class.bindTemplateCallback("notify_position", &propPosition);
|
||||
|
||||
// Virtual methods
|
||||
|
|
|
|||
|
|
@ -169,6 +169,24 @@ pub const Surface = extern struct {
|
|||
);
|
||||
};
|
||||
|
||||
pub const mapped = struct {
|
||||
pub const name = "mapped";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
bool,
|
||||
.{
|
||||
.default = false,
|
||||
.accessor = gobject.ext.privateFieldAccessor(
|
||||
Self,
|
||||
Private,
|
||||
&Private.offset,
|
||||
"mapped",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
pub const @"min-size" = struct {
|
||||
pub const name = "min-size";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
|
|
@ -592,11 +610,15 @@ pub const Surface = extern struct {
|
|||
/// focus events.
|
||||
focused: bool = true,
|
||||
|
||||
/// Whether the GLArea widget is mapped. Some operations like grabbing
|
||||
/// focus only work if a widget is mapped.
|
||||
mapped: bool = false,
|
||||
|
||||
/// Whether this surface is "zoomed" or not. A zoomed surface
|
||||
/// shows up taking the full bounds of a split view.
|
||||
zoom: bool = false,
|
||||
|
||||
/// The GLAarea that renders the actual surface. This is a binding
|
||||
/// The GLArea that renders the actual surface. This is a binding
|
||||
/// to the template so it doesn't have to be unrefed manually.
|
||||
gl_area: *gtk.GLArea,
|
||||
|
||||
|
|
@ -652,6 +674,12 @@ pub const Surface = extern struct {
|
|||
// false by a parent widget.
|
||||
bell_ringing: bool = false,
|
||||
|
||||
// The audio bell's MediaFile, reused across bells so we don't leak a
|
||||
// GStreamer pipeline (and its GL threads) on every ring. Built lazily
|
||||
// on the first audio bell and rebuilt when `bell-audio-path` changes;
|
||||
// unref'd on dispose. See ringBell and media.zig.
|
||||
bell_media: ?*gtk.MediaFile = null,
|
||||
|
||||
/// True if this surface is in an error state. This is currently
|
||||
/// a simple boolean with no additional information on WHAT the
|
||||
/// error state is, because we don't yet need it or use it. For now,
|
||||
|
|
@ -1768,6 +1796,7 @@ pub const Surface = extern struct {
|
|||
priv.mouse_shape = .text;
|
||||
priv.mouse_hidden = false;
|
||||
priv.focused = true;
|
||||
priv.mapped = false;
|
||||
priv.size = .{ .width = 0, .height = 0 };
|
||||
priv.vadj_signal_group = null;
|
||||
|
||||
|
|
@ -1831,6 +1860,11 @@ pub const Surface = extern struct {
|
|||
priv.config = null;
|
||||
}
|
||||
|
||||
if (priv.bell_media) |v| {
|
||||
v.unref();
|
||||
priv.bell_media = null;
|
||||
}
|
||||
|
||||
if (priv.vadj_signal_group) |group| {
|
||||
group.setTarget(null);
|
||||
group.as(gobject.Object).unref();
|
||||
|
|
@ -2019,6 +2053,11 @@ pub const Surface = extern struct {
|
|||
return self.private().focused;
|
||||
}
|
||||
|
||||
/// Returns true if the GLArea of this surface is mapped.
|
||||
pub fn getMapped(self: *Self) bool {
|
||||
return self.private().mapped;
|
||||
}
|
||||
|
||||
/// Change the configuration for this surface.
|
||||
pub fn setConfig(self: *Self, config: *Config) void {
|
||||
const priv = self.private();
|
||||
|
|
@ -2458,8 +2497,15 @@ pub const Surface = extern struct {
|
|||
1.0,
|
||||
);
|
||||
|
||||
const media_file = media.fromFilename(path) orelse break :audio;
|
||||
media.playMediaFile(media_file, volume, required);
|
||||
// Reuse one MediaFile per surface (rebuilt only when the path
|
||||
// changes) so each bell replays the same pipeline instead of
|
||||
// leaking a fresh one. Assign unconditionally: bellMediaFile frees
|
||||
// any stale MediaFile and returns the current slot value (possibly
|
||||
// null if the path is now inaccessible), so priv.bell_media never
|
||||
// dangles.
|
||||
priv.bell_media = media.bellMediaFile(priv.bell_media, path, required);
|
||||
const media_file = priv.bell_media orelse break :audio;
|
||||
media.playBell(media_file, volume);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3250,6 +3296,35 @@ pub const Surface = extern struct {
|
|||
priv.im_context.as(gtk.IMContext).setClientWidget(null);
|
||||
}
|
||||
|
||||
fn glareaMap(
|
||||
_: *gtk.GLArea,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.updateMapped(true);
|
||||
self.updateOcclusion(true);
|
||||
}
|
||||
|
||||
fn glareaUnmap(
|
||||
_: *gtk.GLArea,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.updateMapped(false);
|
||||
self.updateOcclusion(false);
|
||||
}
|
||||
|
||||
fn updateMapped(self: *Self, mapped: bool) void {
|
||||
const priv = self.private();
|
||||
priv.mapped = mapped;
|
||||
self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec);
|
||||
}
|
||||
|
||||
fn updateOcclusion(self: *Self, visible: bool) void {
|
||||
const surface = self.core() orelse return;
|
||||
surface.occlusionCallback(visible) catch |err| {
|
||||
log.warn("error in occlusion callback err={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
fn glareaRender(
|
||||
_: *gtk.GLArea,
|
||||
_: *gdk.GLContext,
|
||||
|
|
@ -3560,6 +3635,8 @@ pub const Surface = extern struct {
|
|||
class.bindTemplateCallback("drop", &dtDrop);
|
||||
class.bindTemplateCallback("gl_realize", &glareaRealize);
|
||||
class.bindTemplateCallback("gl_unrealize", &glareaUnrealize);
|
||||
class.bindTemplateCallback("gl_map", &glareaMap);
|
||||
class.bindTemplateCallback("gl_unmap", &glareaUnmap);
|
||||
class.bindTemplateCallback("gl_render", &glareaRender);
|
||||
class.bindTemplateCallback("gl_resize", &glareaResize);
|
||||
class.bindTemplateCallback("im_preedit_start", &imPreeditStart);
|
||||
|
|
@ -3592,6 +3669,7 @@ pub const Surface = extern struct {
|
|||
properties.@"error".impl,
|
||||
properties.@"font-size-request".impl,
|
||||
properties.focused.impl,
|
||||
properties.mapped.impl,
|
||||
properties.@"key-sequence".impl,
|
||||
properties.@"key-table".impl,
|
||||
properties.@"min-size".impl,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,9 @@ pub const Window = extern struct {
|
|||
/// behaves slightly differently under certain scenarios.
|
||||
quick_terminal: bool = false,
|
||||
|
||||
/// Timeout source to react to this window becoming (in)active.
|
||||
handle_active_state_source: ?c_uint = null,
|
||||
|
||||
/// The window decoration override. If this is not set then we'll
|
||||
/// inherit whatever the config has. This allows overriding the
|
||||
/// config on a per-window basis.
|
||||
|
|
@ -855,6 +858,38 @@ pub const Window = extern struct {
|
|||
}
|
||||
}
|
||||
|
||||
/// Callback to handle this window becoming active or inactive.
|
||||
/// Triggered by propIsActive with a timeout to debounce temporary
|
||||
/// changes in active state.
|
||||
fn handleActiveState(ud: ?*anyopaque) callconv(.c) c_int {
|
||||
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
|
||||
const priv = self.private();
|
||||
priv.handle_active_state_source = null;
|
||||
|
||||
// Hide quick-terminal if set to autohide
|
||||
if (self.isQuickTerminal()) {
|
||||
if (self.getConfig()) |cfg| {
|
||||
if (cfg.get().@"quick-terminal-autohide" and
|
||||
self.as(gtk.Window).isActive() == 0 and
|
||||
self.as(gtk.Widget).isVisible() == 1)
|
||||
{
|
||||
self.toggleVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't change urgency if we're not the active window.
|
||||
if (self.as(gtk.Window).isActive() == 0) return 0;
|
||||
|
||||
self.winproto().setUrgent(false) catch |err| {
|
||||
log.warn(
|
||||
"winproto failed to reset urgency={}",
|
||||
.{err},
|
||||
);
|
||||
};
|
||||
return 0;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
// Properties
|
||||
|
||||
|
|
@ -1076,27 +1111,34 @@ pub const Window = extern struct {
|
|||
_: *gobject.ParamSpec,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
// Hide quick-terminal if set to autohide
|
||||
if (self.isQuickTerminal()) {
|
||||
if (self.getConfig()) |cfg| {
|
||||
if (cfg.get().@"quick-terminal-autohide" and
|
||||
self.as(gtk.Window).isActive() == 0 and
|
||||
self.as(gtk.Widget).isVisible() == 1)
|
||||
{
|
||||
self.toggleVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
const priv = self.private();
|
||||
|
||||
// Don't change urgency if we're not the active window.
|
||||
if (self.as(gtk.Window).isActive() == 0) return;
|
||||
|
||||
self.winproto().setUrgent(false) catch |err| {
|
||||
log.warn(
|
||||
"winproto failed to reset urgency={}",
|
||||
.{err},
|
||||
// Use a timeout callback to wait for focus state to settle,
|
||||
// because depending on the windowing backend the window might
|
||||
// become inactive and immediately active again. This happens
|
||||
// e.g. on Wayland when opening a context menu or a submenu
|
||||
// inside a context menu.
|
||||
if (priv.handle_active_state_source == null) {
|
||||
priv.handle_active_state_source = glib.timeoutAddFull(
|
||||
// Use priority of an idle callback instead of the higher
|
||||
// default timeout priority. This allows us to use a shorter
|
||||
// timeout duration.
|
||||
glib.PRIORITY_DEFAULT_IDLE,
|
||||
// 50ms was chosen to be conservative. From testing we know
|
||||
// that, depending on the backend and system performance, a
|
||||
// shorter timeout or just an idle callback can be enough for
|
||||
// the focus to settle. On the other hand a delay of e.g. 10ms
|
||||
// does not work reliably on some slow systems. The downside
|
||||
// of a high value is that some operations in handleActiveState,
|
||||
// e.g. hiding the quick-terminal, will be visibly delayed.
|
||||
// However, 50ms should barely be noticeable. We can change
|
||||
// this in the future if necessary.
|
||||
50,
|
||||
handleActiveState,
|
||||
self,
|
||||
null,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn propGdkSurfaceDims(
|
||||
|
|
@ -1215,6 +1257,13 @@ pub const Window = extern struct {
|
|||
fn dispose(self: *Self) callconv(.c) void {
|
||||
const priv = self.private();
|
||||
|
||||
if (priv.handle_active_state_source) |v| {
|
||||
if (glib.Source.remove(v) == 0) {
|
||||
log.warn("unable to remove handle active state source", .{});
|
||||
}
|
||||
priv.handle_active_state_source = null;
|
||||
}
|
||||
|
||||
priv.command_palette.set(null);
|
||||
|
||||
if (priv.config) |v| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const apprt = @import("../../../apprt.zig");
|
||||
const DBus = @import("DBus.zig");
|
||||
|
||||
/// Use a D-Bus method call to toggle the quick terminal on GTK.
|
||||
///
|
||||
/// `ghostty +toggle-quick-terminal` is equivalent to the following command
|
||||
/// (on a release build):
|
||||
///
|
||||
/// ```sh
|
||||
/// gdbus call --session \
|
||||
/// --dest com.mitchellh.ghostty \
|
||||
/// --object-path /com/mitchellh/ghostty \
|
||||
/// --method org.gtk.Actions.Activate \
|
||||
/// toggle-quick-terminal [] []
|
||||
/// ```
|
||||
pub fn toggleQuickTerminal(alloc: Allocator, target: apprt.ipc.Target) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool {
|
||||
var dbus = try DBus.init(alloc, target, "toggle-quick-terminal");
|
||||
defer dbus.deinit(alloc);
|
||||
try dbus.send();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -44,9 +44,38 @@ pub fn fromResource(path: [:0]const u8) ?*gtk.MediaFile {
|
|||
return gtk.MediaFile.newForResource(path);
|
||||
}
|
||||
|
||||
pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) void {
|
||||
// If the audio file is marked as required, we'll emit an error if
|
||||
// there was a problem playing it. Otherwise there will be silence.
|
||||
/// Get-or-create a reusable bell MediaFile targeting `path`.
|
||||
///
|
||||
/// `current` is the surface's currently-cached MediaFile (or null). If it
|
||||
/// already targets `path` it is returned unchanged; otherwise it is unref'd and
|
||||
/// a fresh MediaFile is built for `path`. Returns null (after freeing `current`)
|
||||
/// if `path` is inaccessible, leaving the caller's slot empty.
|
||||
///
|
||||
/// Reusing one MediaFile per surface is what prevents the GStreamer pipeline
|
||||
/// leak: `gtk.MediaFile.newForFilename` spins up a full pipeline (and, via the
|
||||
/// GTK4 GStreamer backend's GL sink, gstglcontext/gldisplay-event threads) that
|
||||
/// is never torn down on the happy path, so allocating one per bell leaked a
|
||||
/// pipeline + its threads on every ring. See the caller in surface.zig.
|
||||
pub fn bellMediaFile(
|
||||
current: ?*gtk.MediaFile,
|
||||
path: [:0]const u8,
|
||||
required: bool,
|
||||
) ?*gtk.MediaFile {
|
||||
if (current) |media_file| {
|
||||
if (isForPath(media_file, path)) return media_file;
|
||||
media_file.unref();
|
||||
}
|
||||
|
||||
const media_file = fromFilename(path) orelse return null;
|
||||
|
||||
// If the audio file is marked as required, we'll emit an error if there
|
||||
// was a problem playing it. Otherwise there will be silence. We connect
|
||||
// this once, here, because the MediaFile is reused across bells.
|
||||
//
|
||||
// NOTE: we intentionally do NOT connect notify::ended to unref. The
|
||||
// MediaFile is owned by the surface and replayed via `seek(0)` for every
|
||||
// bell; unref'ing on `ended` is precisely what previously discarded (and
|
||||
// leaked) a pipeline per ring.
|
||||
if (required) {
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
media_file,
|
||||
|
|
@ -57,21 +86,27 @@ pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) vo
|
|||
);
|
||||
}
|
||||
|
||||
// Watch for the "ended" signal so that we can clean up after
|
||||
// ourselves.
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
media_file,
|
||||
?*anyopaque,
|
||||
mediaFileEnded,
|
||||
null,
|
||||
.{ .detail = "ended" },
|
||||
);
|
||||
return media_file;
|
||||
}
|
||||
|
||||
/// (Re)play `media_file` at `volume`. `seek(0)` rewinds first so that a
|
||||
/// previously-ended stream plays again; without it playback only ever happens
|
||||
/// once (see #8957). Safe on a freshly-created stream as well.
|
||||
pub fn playBell(media_file: *gtk.MediaFile, volume: f64) void {
|
||||
const media_stream = media_file.as(gtk.MediaStream);
|
||||
media_stream.setVolume(volume);
|
||||
media_stream.seek(0);
|
||||
media_stream.play();
|
||||
}
|
||||
|
||||
/// Whether `media_file` was created for `path`.
|
||||
fn isForPath(media_file: *gtk.MediaFile, path: [:0]const u8) bool {
|
||||
const file = media_file.getFile() orelse return false;
|
||||
const cur = file.getPath() orelse return false;
|
||||
defer glib.free(cur);
|
||||
return std.mem.eql(u8, std.mem.span(cur), path);
|
||||
}
|
||||
|
||||
fn mediaFileError(
|
||||
media_file: *gtk.MediaFile,
|
||||
_: *gobject.ParamSpec,
|
||||
|
|
@ -93,10 +128,30 @@ fn mediaFileError(
|
|||
});
|
||||
}
|
||||
|
||||
fn mediaFileEnded(
|
||||
media_file: *gtk.MediaFile,
|
||||
_: *gobject.ParamSpec,
|
||||
_: ?*anyopaque,
|
||||
) callconv(.c) void {
|
||||
media_file.unref();
|
||||
test "bellMediaFile reuses one MediaFile per path" {
|
||||
// Regression guard for the audio-bell thread leak: each bell must replay a
|
||||
// single cached MediaFile, not allocate a fresh GStreamer pipeline (which
|
||||
// leaked gstglcontext/gldisplay-event threads) per ring. We assert the
|
||||
// reuse contract of bellMediaFile directly; this needs no display and no
|
||||
// playback (MediaFile is lazy), only that the path comparison drives reuse.
|
||||
const testing = std.testing;
|
||||
|
||||
// The files need not exist: MediaFile only records the path until played.
|
||||
const path_a: [:0]const u8 = "/tmp/ghostty-bell-test-a.oga";
|
||||
const path_b: [:0]const u8 = "/tmp/ghostty-bell-test-b.oga";
|
||||
|
||||
var current = bellMediaFile(null, path_a, false) orelse return error.SkipZigTest;
|
||||
const first = current;
|
||||
try testing.expect(isForPath(current, path_a));
|
||||
|
||||
// Same path => identical object (the leak regression is rebuilding here).
|
||||
current = bellMediaFile(current, path_a, false).?;
|
||||
try testing.expectEqual(first, current);
|
||||
|
||||
// Changed path => rebuilt object targeting the new path (old one freed).
|
||||
current = bellMediaFile(current, path_b, false) orelse return error.SkipZigTest;
|
||||
try testing.expect(isForPath(current, path_b));
|
||||
try testing.expect(!isForPath(current, path_a));
|
||||
|
||||
current.unref();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ Overlay terminal_page {
|
|||
GLArea gl_area {
|
||||
realize => $gl_realize();
|
||||
unrealize => $gl_unrealize();
|
||||
map => $gl_map();
|
||||
unmap => $gl_unmap();
|
||||
render => $gl_render();
|
||||
resize => $gl_resize();
|
||||
hexpand: true;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ template $GhosttySplitTreeSplit: Adw.Bin {
|
|||
Adw.Bin {
|
||||
Paned paned {
|
||||
notify::max-position => $notify_max_position();
|
||||
notify::min-position => $notify_min_position();
|
||||
notify::position => $notify_position();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ pub const Action = union(enum) {
|
|||
/// The arguments to pass to Ghostty as the command.
|
||||
new_window: NewWindow,
|
||||
|
||||
/// Toggle the quick terminal.
|
||||
toggle_quick_terminal: void,
|
||||
|
||||
pub const NewWindow = struct {
|
||||
/// A list of command arguments to launch in the new window. If this is
|
||||
/// `null` the command configured in the config or the user's default
|
||||
|
|
@ -113,6 +116,7 @@ pub const Action = union(enum) {
|
|||
/// Sync with: ghostty_ipc_action_tag_e
|
||||
pub const Key = enum(c_int) {
|
||||
new_window,
|
||||
toggle_quick_terminal,
|
||||
|
||||
test "ghostty.h Action.Key" {
|
||||
try lib.checkGhosttyHEnum(Key, "GHOSTTY_IPC_ACTION_");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const list_keybinds = @import("list_keybinds.zig");
|
|||
const list_themes = @import("list_themes.zig");
|
||||
const list_colors = @import("list_colors.zig");
|
||||
const list_actions = @import("list_actions.zig");
|
||||
const ssh = @import("ssh.zig");
|
||||
const ssh_cache = @import("ssh_cache.zig");
|
||||
const edit_config = @import("edit_config.zig");
|
||||
const show_config = @import("show_config.zig");
|
||||
|
|
@ -20,6 +21,7 @@ const crash_report = @import("crash_report.zig");
|
|||
const show_face = @import("show_face.zig");
|
||||
const boo = @import("boo.zig");
|
||||
const new_window = @import("new_window.zig");
|
||||
const toggle_quick_terminal = @import("toggle_quick_terminal.zig");
|
||||
|
||||
/// Special commands that can be invoked via CLI flags. These are all
|
||||
/// invoked by using `+<action>` as a CLI flag. The only exception is
|
||||
|
|
@ -46,6 +48,9 @@ pub const Action = enum {
|
|||
/// List keybind actions
|
||||
@"list-actions",
|
||||
|
||||
/// Wrap `ssh` to configure Ghostty terminal integration on remote hosts
|
||||
ssh,
|
||||
|
||||
/// Manage SSH terminfo cache for automatic remote host setup
|
||||
@"ssh-cache",
|
||||
|
||||
|
|
@ -73,6 +78,9 @@ pub const Action = enum {
|
|||
// Use IPC to tell the running Ghostty to open a new window.
|
||||
@"new-window",
|
||||
|
||||
// Use IPC to tell the running Ghostty to toggle the quick terminal.
|
||||
@"toggle-quick-terminal",
|
||||
|
||||
pub fn detectSpecialCase(arg: []const u8) ?SpecialCase(Action) {
|
||||
// If we see a "-e" and we haven't seen a command yet, then
|
||||
// we are done looking for commands. This special case enables
|
||||
|
|
@ -144,6 +152,7 @@ pub const Action = enum {
|
|||
.@"list-colors" => try list_colors.run(alloc),
|
||||
.@"list-actions" => try list_actions.run(alloc),
|
||||
.@"ssh-cache" => try ssh_cache.run(alloc),
|
||||
.ssh => try ssh.run(alloc),
|
||||
.@"edit-config" => try edit_config.run(alloc),
|
||||
.@"show-config" => try show_config.run(alloc),
|
||||
.@"explain-config" => try explain_config.run(alloc),
|
||||
|
|
@ -152,6 +161,7 @@ pub const Action = enum {
|
|||
.@"show-face" => try show_face.run(alloc),
|
||||
.boo => try boo.run(alloc),
|
||||
.@"new-window" => try new_window.run(alloc),
|
||||
.@"toggle-quick-terminal" => try toggle_quick_terminal.run(alloc),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +194,7 @@ pub const Action = enum {
|
|||
.@"list-colors" => list_colors.Options,
|
||||
.@"list-actions" => list_actions.Options,
|
||||
.@"ssh-cache" => ssh_cache.Options,
|
||||
.ssh => ssh.Options,
|
||||
.@"edit-config" => edit_config.Options,
|
||||
.@"show-config" => show_config.Options,
|
||||
.@"explain-config" => explain_config.Options,
|
||||
|
|
@ -192,6 +203,7 @@ pub const Action = enum {
|
|||
.@"show-face" => show_face.Options,
|
||||
.boo => boo.Options,
|
||||
.@"new-window" => new_window.Options,
|
||||
.@"toggle-quick-terminal" => toggle_quick_terminal.Options,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,6 @@ const MAX_CACHE_SIZE = 512 * 1024;
|
|||
/// Path to a file where the cache is stored.
|
||||
path: []const u8,
|
||||
|
||||
pub const DefaultPathError = Allocator.Error || error{
|
||||
/// The general error that is returned for any filesystem error
|
||||
/// that may have resulted in the XDG lookup failing.
|
||||
XdgLookupFailed,
|
||||
};
|
||||
|
||||
pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
|
||||
|
||||
/// Returns the default path for the cache for a given program.
|
||||
///
|
||||
/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
|
||||
|
|
@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
|
|||
pub fn defaultPath(
|
||||
alloc: Allocator,
|
||||
program: []const u8,
|
||||
) DefaultPathError![]const u8 {
|
||||
) ![]const u8 {
|
||||
const state_dir: []const u8 = xdg.state(
|
||||
alloc,
|
||||
.{ .subdir = program },
|
||||
|
|
@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void {
|
|||
};
|
||||
}
|
||||
|
||||
pub const AddResult = enum { added, updated };
|
||||
|
||||
pub const AddError = std.fs.Dir.MakeError ||
|
||||
std.fs.Dir.StatFileError ||
|
||||
std.fs.File.OpenError ||
|
||||
std.fs.File.ChmodError ||
|
||||
std.io.Reader.LimitedAllocError ||
|
||||
FixupPermissionsError ||
|
||||
ReadEntriesError ||
|
||||
WriteCacheFileError ||
|
||||
Error;
|
||||
|
||||
/// Add or update a hostname entry in the cache.
|
||||
/// Returns AddResult.added for new entries or AddResult.updated for existing ones.
|
||||
/// Add or update an entry in the cache, recording `timestamp` (Unix seconds).
|
||||
/// The cache file is created if it doesn't exist with secure permissions (0600).
|
||||
pub fn add(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) AddError!AddResult {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
key: []const u8,
|
||||
timestamp: i64,
|
||||
) !void {
|
||||
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||
|
||||
// Create cache directory if needed
|
||||
if (std.fs.path.dirname(self.path)) |dir| {
|
||||
|
|
@ -107,58 +87,49 @@ pub fn add(
|
|||
// Lock
|
||||
// Causes a compile failure in the Zig std library on Windows, see:
|
||||
// https://github.com/ziglang/zig/issues/18430
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
|
||||
defer if (comptime builtin.os.tag != .windows) file.unlock();
|
||||
|
||||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
// Add or update entry
|
||||
const gop = try entries.getOrPut(hostname);
|
||||
const result: AddResult = if (!gop.found_existing) add: {
|
||||
const hostname_copy = try alloc.dupe(u8, hostname);
|
||||
errdefer alloc.free(hostname_copy);
|
||||
// Update the timestamp of an existing entry, or insert a new one. For a
|
||||
// new entry, dupe both strings up front so a failed allocation never
|
||||
// leaves a half-built slot (borrowed key, undefined value) for the
|
||||
// `deinitEntries` defer to walk.
|
||||
if (entries.getPtr(key)) |existing| {
|
||||
existing.timestamp = timestamp;
|
||||
} else {
|
||||
const key_copy = try alloc.dupe(u8, key);
|
||||
errdefer alloc.free(key_copy);
|
||||
const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty");
|
||||
errdefer alloc.free(terminfo_copy);
|
||||
|
||||
gop.key_ptr.* = hostname_copy;
|
||||
gop.value_ptr.* = .{
|
||||
.hostname = gop.key_ptr.*,
|
||||
.timestamp = std.time.timestamp(),
|
||||
try entries.put(key_copy, .{
|
||||
.hostname = key_copy,
|
||||
.timestamp = timestamp,
|
||||
.terminfo_version = terminfo_copy,
|
||||
};
|
||||
break :add .added;
|
||||
} else update: {
|
||||
// Update timestamp for existing entry
|
||||
gop.value_ptr.timestamp = std.time.timestamp();
|
||||
break :update .updated;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
try self.writeCacheFile(entries, null);
|
||||
return result;
|
||||
try self.writeCacheFile(entries);
|
||||
}
|
||||
|
||||
pub const RemoveError = std.fs.File.OpenError ||
|
||||
FixupPermissionsError ||
|
||||
ReadEntriesError ||
|
||||
WriteCacheFileError ||
|
||||
Error;
|
||||
|
||||
/// Remove a hostname entry from the cache.
|
||||
/// No error is returned if the hostname doesn't exist or the cache file is missing.
|
||||
/// Remove an entry from the cache. Returns true if an entry was removed,
|
||||
/// false if the key wasn't present (or the cache file is missing).
|
||||
pub fn remove(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) RemoveError!void {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
key: []const u8,
|
||||
) !bool {
|
||||
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||
|
||||
// Open our file
|
||||
const file = std.fs.openFileAbsolute(
|
||||
self.path,
|
||||
.{ .mode = .read_write },
|
||||
) catch |err| switch (err) {
|
||||
error.FileNotFound => return,
|
||||
error.FileNotFound => return false,
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
|
|
@ -167,7 +138,7 @@ pub fn remove(
|
|||
// Lock
|
||||
// Causes a compile failure in the Zig std library on Windows, see:
|
||||
// https://github.com/ziglang/zig/issues/18430
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
|
||||
defer if (comptime builtin.os.tag != .windows) file.unlock();
|
||||
|
||||
// Read existing entries
|
||||
|
|
@ -175,27 +146,73 @@ pub fn remove(
|
|||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
// Remove the entry if it exists and ensure we free the memory
|
||||
if (entries.fetchRemove(hostname)) |kv| {
|
||||
const removed = if (entries.fetchRemove(key)) |kv| removed: {
|
||||
assert(kv.key.ptr == kv.value.hostname.ptr);
|
||||
alloc.free(kv.value.hostname);
|
||||
alloc.free(kv.value.terminfo_version);
|
||||
break :removed true;
|
||||
} else false;
|
||||
|
||||
try self.writeCacheFile(entries);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// Remove all entries older than `max_age_s` seconds and return how many
|
||||
/// were pruned. Returns zero (and nothing written) if the cache file is
|
||||
/// missing.
|
||||
pub fn prune(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
max_age_s: u64,
|
||||
) !usize {
|
||||
const file = std.fs.openFileAbsolute(
|
||||
self.path,
|
||||
.{ .mode = .read_write },
|
||||
) catch |err| switch (err) {
|
||||
error.FileNotFound => return 0,
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
try fixupPermissions(file);
|
||||
|
||||
// Lock
|
||||
// Causes a compile failure in the Zig std library on Windows, see:
|
||||
// https://github.com/ziglang/zig/issues/18430
|
||||
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
|
||||
defer if (comptime builtin.os.tag != .windows) file.unlock();
|
||||
|
||||
// Read existing entries
|
||||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
// Drop expired entries from the map, then persist what remains.
|
||||
const now = std.time.timestamp();
|
||||
var expired: std.ArrayList([]const u8) = .empty;
|
||||
defer expired.deinit(alloc);
|
||||
var iter = entries.iterator();
|
||||
while (iter.next()) |kv| {
|
||||
const age_s = now -| kv.value_ptr.timestamp;
|
||||
if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*);
|
||||
}
|
||||
for (expired.items) |key| {
|
||||
const kv = entries.fetchRemove(key).?;
|
||||
assert(kv.key.ptr == kv.value.hostname.ptr);
|
||||
alloc.free(kv.value.hostname);
|
||||
alloc.free(kv.value.terminfo_version);
|
||||
}
|
||||
|
||||
try self.writeCacheFile(entries, null);
|
||||
try self.writeCacheFile(entries);
|
||||
return expired.items.len;
|
||||
}
|
||||
|
||||
pub const ContainsError = std.fs.File.OpenError ||
|
||||
ReadEntriesError ||
|
||||
error{HostnameIsInvalid};
|
||||
|
||||
/// Check if a hostname exists in the cache.
|
||||
/// Check if a key exists in the cache.
|
||||
/// Returns false if the cache file doesn't exist.
|
||||
pub fn contains(
|
||||
self: DiskCache,
|
||||
alloc: Allocator,
|
||||
hostname: []const u8,
|
||||
) ContainsError!bool {
|
||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
||||
key: []const u8,
|
||||
) !bool {
|
||||
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||
|
||||
// Open our file
|
||||
const file = std.fs.openFileAbsolute(
|
||||
|
|
@ -211,12 +228,10 @@ pub fn contains(
|
|||
var entries = try readEntries(alloc, file);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
return entries.contains(hostname);
|
||||
return entries.contains(key);
|
||||
}
|
||||
|
||||
pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError);
|
||||
|
||||
fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
|
||||
fn fixupPermissions(file: std.fs.File) !void {
|
||||
// Windows does not support chmod
|
||||
if (comptime builtin.os.tag == .windows) return;
|
||||
|
||||
|
|
@ -228,18 +243,10 @@ fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
|
|||
}
|
||||
}
|
||||
|
||||
pub const WriteCacheFileError = std.fs.Dir.OpenError ||
|
||||
std.fs.AtomicFile.InitError ||
|
||||
std.fs.AtomicFile.FlushError ||
|
||||
std.fs.AtomicFile.FinishError ||
|
||||
Entry.FormatError ||
|
||||
error{InvalidCachePath};
|
||||
|
||||
fn writeCacheFile(
|
||||
self: DiskCache,
|
||||
entries: std.StringHashMap(Entry),
|
||||
expire_days: ?u32,
|
||||
) WriteCacheFileError!void {
|
||||
) !void {
|
||||
const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
|
||||
const cache_basename = std.fs.path.basename(self.path);
|
||||
|
||||
|
|
@ -255,8 +262,6 @@ fn writeCacheFile(
|
|||
|
||||
var iter = entries.iterator();
|
||||
while (iter.next()) |kv| {
|
||||
// Only write non-expired entries
|
||||
if (kv.value_ptr.isExpired(expire_days)) continue;
|
||||
try kv.value_ptr.format(&atomic_file.file_writer.interface);
|
||||
}
|
||||
|
||||
|
|
@ -299,12 +304,10 @@ pub fn deinitEntries(
|
|||
entries.deinit();
|
||||
}
|
||||
|
||||
pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError;
|
||||
|
||||
fn readEntries(
|
||||
alloc: Allocator,
|
||||
file: std.fs.File,
|
||||
) ReadEntriesError!std.StringHashMap(Entry) {
|
||||
) !std.StringHashMap(Entry) {
|
||||
var reader = file.reader(&.{});
|
||||
const content = try reader.interface.allocRemaining(
|
||||
alloc,
|
||||
|
|
@ -313,26 +316,35 @@ fn readEntries(
|
|||
defer alloc.free(content);
|
||||
|
||||
var entries = std.StringHashMap(Entry).init(alloc);
|
||||
errdefer deinitEntries(alloc, &entries);
|
||||
|
||||
var lines = std.mem.tokenizeScalar(u8, content, '\n');
|
||||
while (lines.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \t\r");
|
||||
const entry = Entry.parse(trimmed) orelse continue;
|
||||
|
||||
// Always allocate hostname first to avoid key pointer confusion
|
||||
const hostname = try alloc.dupe(u8, entry.hostname);
|
||||
errdefer alloc.free(hostname);
|
||||
// Dupe both strings up front, before inserting, so the map never
|
||||
// holds a half-built entry (a borrowed key or a freed/undefined
|
||||
// value) for `deinitEntries` to walk if an allocation fails.
|
||||
var hostname: ?[]u8 = try alloc.dupe(u8, entry.hostname);
|
||||
errdefer if (hostname) |h| alloc.free(h);
|
||||
var terminfo: ?[]u8 = try alloc.dupe(u8, entry.terminfo_version);
|
||||
errdefer if (terminfo) |t| alloc.free(t);
|
||||
|
||||
const gop = try entries.getOrPut(hostname);
|
||||
const gop = try entries.getOrPut(hostname.?);
|
||||
if (!gop.found_existing) {
|
||||
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
|
||||
// New entry: transfer both copies to the map.
|
||||
gop.value_ptr.* = .{
|
||||
.hostname = hostname,
|
||||
.hostname = hostname.?,
|
||||
.timestamp = entry.timestamp,
|
||||
.terminfo_version = terminfo_copy,
|
||||
.terminfo_version = terminfo.?,
|
||||
};
|
||||
hostname = null;
|
||||
terminfo = null;
|
||||
} else {
|
||||
// Don't need the copy since entry already exists
|
||||
alloc.free(hostname);
|
||||
// Duplicate key: the map keeps its existing key, so free ours.
|
||||
alloc.free(hostname.?);
|
||||
hostname = null;
|
||||
|
||||
// Handle duplicate entries - keep newer timestamp
|
||||
if (entry.timestamp > gop.value_ptr.timestamp) {
|
||||
|
|
@ -340,13 +352,15 @@ fn readEntries(
|
|||
if (!std.mem.eql(
|
||||
u8,
|
||||
gop.value_ptr.terminfo_version,
|
||||
entry.terminfo_version,
|
||||
terminfo.?,
|
||||
)) {
|
||||
alloc.free(gop.value_ptr.terminfo_version);
|
||||
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
|
||||
gop.value_ptr.terminfo_version = terminfo_copy;
|
||||
gop.value_ptr.terminfo_version = terminfo.?;
|
||||
terminfo = null;
|
||||
}
|
||||
}
|
||||
if (terminfo) |t| alloc.free(t);
|
||||
terminfo = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,7 +368,7 @@ fn readEntries(
|
|||
}
|
||||
|
||||
// Supports both standalone hostnames and user@hostname format
|
||||
fn isValidCacheKey(key: []const u8) bool {
|
||||
pub fn isValidCacheKey(key: []const u8) bool {
|
||||
if (key.len == 0) return false;
|
||||
|
||||
// Check for user@hostname format
|
||||
|
|
@ -452,33 +466,23 @@ test "disk cache operations" {
|
|||
const path = try tmp.dir.realpathAlloc(alloc, "cache");
|
||||
defer alloc.free(path);
|
||||
|
||||
// Setup our cache
|
||||
// Setup our cache. Adding the same key twice exercises both the new
|
||||
// and existing-entry paths.
|
||||
const cache: DiskCache = .{ .path = path };
|
||||
try testing.expectEqual(
|
||||
AddResult.added,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
AddResult.updated,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
try testing.expect(
|
||||
try cache.contains(alloc, "example.com"),
|
||||
);
|
||||
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||
try testing.expect(try cache.contains(alloc, "example.com"));
|
||||
|
||||
// List
|
||||
var entries = try cache.list(alloc);
|
||||
deinitEntries(alloc, &entries);
|
||||
|
||||
// Remove
|
||||
try cache.remove(alloc, "example.com");
|
||||
try testing.expect(
|
||||
!(try cache.contains(alloc, "example.com")),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
AddResult.added,
|
||||
try cache.add(alloc, "example.com"),
|
||||
);
|
||||
// Remove reports that it removed the entry, and a second remove of the
|
||||
// same key reports nothing to remove.
|
||||
try testing.expect(try cache.remove(alloc, "example.com"));
|
||||
try testing.expect(!try cache.remove(alloc, "example.com"));
|
||||
try testing.expect(!(try cache.contains(alloc, "example.com")));
|
||||
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||
}
|
||||
|
||||
test "disk cache cleans up temp files" {
|
||||
|
|
@ -494,8 +498,8 @@ test "disk cache cleans up temp files" {
|
|||
defer alloc.free(cache_path);
|
||||
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com"));
|
||||
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org"));
|
||||
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||
try cache.add(alloc, "example.org", std.time.timestamp());
|
||||
|
||||
// Verify only the cache file exists and no temp files left behind
|
||||
var count: usize = 0;
|
||||
|
|
@ -507,6 +511,170 @@ test "disk cache cleans up temp files" {
|
|||
try testing.expectEqual(1, count);
|
||||
}
|
||||
|
||||
test "disk cache prune" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
|
||||
defer alloc.free(tmp_path);
|
||||
const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
|
||||
defer alloc.free(cache_path);
|
||||
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
|
||||
// Back-date one entry an hour old and one 100 days old.
|
||||
const day = std.time.s_per_day;
|
||||
const hour = std.time.s_per_hour;
|
||||
const now = std.time.timestamp();
|
||||
try cache.add(alloc, "recent.com", now - hour);
|
||||
try cache.add(alloc, "old.com", now - 100 * day);
|
||||
|
||||
// Prune entries older than 90 days: only old.com goes.
|
||||
try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day));
|
||||
try testing.expect(try cache.contains(alloc, "recent.com"));
|
||||
try testing.expect(!try cache.contains(alloc, "old.com"));
|
||||
|
||||
// Pruning again removes nothing.
|
||||
try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day));
|
||||
|
||||
// Sub-day granularity: a 30-minute max age prunes the hour-old entry.
|
||||
try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min));
|
||||
try testing.expect(!try cache.contains(alloc, "recent.com"));
|
||||
}
|
||||
|
||||
test "disk cache prune missing file" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
|
||||
defer alloc.free(tmp_path);
|
||||
const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
|
||||
defer alloc.free(cache_path);
|
||||
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 30));
|
||||
}
|
||||
|
||||
test "disk cache reads duplicate keys" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
// Exercise readEntries' found_existing branch: replace the existing
|
||||
// key with the updated entry and ensure (via testing.allocator) that
|
||||
// we don't double-free or leak.
|
||||
{
|
||||
var file = try tmp.dir.createFile("cache", .{});
|
||||
defer file.close();
|
||||
var buf: [256]u8 = undefined;
|
||||
var file_writer = file.writer(&buf);
|
||||
try file_writer.interface.writeAll(
|
||||
"example.com|100|xterm-ghostty\nexample.com|200|xterm-newer\n",
|
||||
);
|
||||
try file_writer.interface.flush();
|
||||
}
|
||||
const path = try tmp.dir.realpathAlloc(alloc, "cache");
|
||||
defer alloc.free(path);
|
||||
|
||||
const cache: DiskCache = .{ .path = path };
|
||||
var entries = try cache.list(alloc);
|
||||
defer deinitEntries(alloc, &entries);
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), entries.count());
|
||||
const entry = entries.get("example.com").?;
|
||||
try testing.expectEqual(@as(i64, 200), entry.timestamp);
|
||||
try testing.expectEqualStrings("xterm-newer", entry.terminfo_version);
|
||||
}
|
||||
|
||||
test "disk cache reads survive allocation failure" {
|
||||
const testing = std.testing;
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
// Exercise a populated cache containing a duplicate key to ensure
|
||||
// that we hit all of the possible allocation behaviors below.
|
||||
{
|
||||
var file = try tmp.dir.createFile("cache", .{});
|
||||
defer file.close();
|
||||
var buf: [256]u8 = undefined;
|
||||
var file_writer = file.writer(&buf);
|
||||
try file_writer.interface.writeAll(
|
||||
"a.com|100|xterm-ghostty\n" ++
|
||||
"b.com|100|xterm-ghostty\n" ++
|
||||
"c.com|100|xterm-ghostty\n" ++
|
||||
"a.com|200|xterm-newer\n",
|
||||
);
|
||||
try file_writer.interface.flush();
|
||||
}
|
||||
const path = try tmp.dir.realpathAlloc(testing.allocator, "cache");
|
||||
defer testing.allocator.free(path);
|
||||
|
||||
const cache: DiskCache = .{ .path = path };
|
||||
|
||||
// Fail the Nth allocation for every N until the read completes. The
|
||||
// FailingAllocator is backed by testing.allocator so we also ensure
|
||||
// that we don't double-free or leak; this can only completely succeed
|
||||
// or fail with OutOfMemory.
|
||||
var fail_index: usize = 0;
|
||||
while (true) : (fail_index += 1) {
|
||||
var failing = std.testing.FailingAllocator.init(
|
||||
testing.allocator,
|
||||
.{ .fail_index = fail_index },
|
||||
);
|
||||
const alloc = failing.allocator();
|
||||
|
||||
if (cache.list(alloc)) |entries_const| {
|
||||
var entries = entries_const;
|
||||
deinitEntries(alloc, &entries);
|
||||
// Reached a run with no induced failure: every path covered.
|
||||
if (!failing.has_induced_failure) break;
|
||||
} else |err| {
|
||||
try testing.expectEqual(error.OutOfMemory, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "disk cache add survives allocation failure" {
|
||||
const testing = std.testing;
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, ".");
|
||||
defer testing.allocator.free(tmp_path);
|
||||
const path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "cache" });
|
||||
defer testing.allocator.free(path);
|
||||
|
||||
const cache: DiskCache = .{ .path = path };
|
||||
|
||||
// Fail the Nth allocation for every N until add completes. A failed add
|
||||
// must not leak or leave a half-built map entry. The FailingAllocator
|
||||
// is backed by testing.allocator to catch either. Each iteration starts
|
||||
// from a clean cache file.
|
||||
var fail_index: usize = 0;
|
||||
while (true) : (fail_index += 1) {
|
||||
std.fs.cwd().deleteFile(path) catch {};
|
||||
var failing = std.testing.FailingAllocator.init(
|
||||
testing.allocator,
|
||||
.{ .fail_index = fail_index },
|
||||
);
|
||||
const alloc = failing.allocator();
|
||||
|
||||
if (cache.add(alloc, "user@example.com", 100)) |_| {
|
||||
if (!failing.has_induced_failure) break;
|
||||
} else |err| {
|
||||
try testing.expectEqual(error.OutOfMemory, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test isValidHost {
|
||||
const testing = std.testing;
|
||||
|
||||
|
|
|
|||
|
|
@ -42,61 +42,6 @@ pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void {
|
|||
);
|
||||
}
|
||||
|
||||
pub fn isExpired(self: Entry, expire_days_: ?u32) bool {
|
||||
const expire_days = expire_days_ orelse return false;
|
||||
const now = std.time.timestamp();
|
||||
const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day);
|
||||
return age_days > expire_days;
|
||||
}
|
||||
|
||||
test "cache entry expiration" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
const fresh_entry: Entry = .{
|
||||
.hostname = "test.com",
|
||||
.timestamp = now - std.time.s_per_day, // 1 day old
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!fresh_entry.isExpired(90));
|
||||
|
||||
const old_entry: Entry = .{
|
||||
.hostname = "old.com",
|
||||
.timestamp = now - (std.time.s_per_day * 100), // 100 days old
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(old_entry.isExpired(90));
|
||||
|
||||
// Test never-expire case
|
||||
try testing.expect(!old_entry.isExpired(null));
|
||||
}
|
||||
|
||||
test "cache entry expiration exact boundary" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
// Exactly at expiration boundary
|
||||
const boundary_entry: Entry = .{
|
||||
.hostname = "example.com",
|
||||
.timestamp = now - (std.time.s_per_day * 30),
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!boundary_entry.isExpired(30));
|
||||
try testing.expect(boundary_entry.isExpired(29));
|
||||
}
|
||||
|
||||
test "cache entry expiration large timestamp" {
|
||||
const testing = std.testing;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
const boundary_entry: Entry = .{
|
||||
.hostname = "example.com",
|
||||
.timestamp = now + (std.time.s_per_day * 30),
|
||||
.terminfo_version = "xterm-ghostty",
|
||||
};
|
||||
try testing.expect(!boundary_entry.isExpired(30));
|
||||
}
|
||||
|
||||
test "cache entry parsing valid formats" {
|
||||
const testing = std.testing;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,635 @@
|
|||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const cli_args = @import("args.zig");
|
||||
const diagnostics = @import("diagnostics.zig");
|
||||
const Action = @import("ghostty.zig").Action;
|
||||
const DiskCache = @import("ssh_cache.zig").DiskCache;
|
||||
const internal_os = @import("../os/main.zig");
|
||||
const ghostty_terminfo = @import("../terminfo/main.zig").ghostty;
|
||||
|
||||
const log = std.log.scoped(.ssh);
|
||||
|
||||
const usage =
|
||||
\\Usage: ghostty +ssh [flags] [--] <ssh args...>
|
||||
\\
|
||||
\\Flags:
|
||||
\\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true.
|
||||
\\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true.
|
||||
\\ --cache[=bool] Use the terminfo install cache. Default: true.
|
||||
\\ --ssh=<path> Path to the ssh binary. Default: first `ssh` on PATH.
|
||||
\\ --verbose Print +ssh status lines to stderr.
|
||||
\\ --help Show full help.
|
||||
\\
|
||||
\\ssh flags and the destination go after +ssh's own flags (or after `--`).
|
||||
\\
|
||||
;
|
||||
|
||||
pub const Options = struct {
|
||||
/// Set by the CLI parser for deinit.
|
||||
_arena: ?ArenaAllocator = null,
|
||||
|
||||
/// Maps to the `ssh-env` shell integration feature.
|
||||
@"forward-env": bool = true,
|
||||
|
||||
/// Maps to the `ssh-terminfo` shell integration feature.
|
||||
terminfo: bool = true,
|
||||
|
||||
/// When false, both cache read and write are bypassed.
|
||||
cache: bool = true,
|
||||
|
||||
/// The wrapped `ssh` binary.
|
||||
/// `/`-containing values are treated as paths; otherwise resolved via PATH.
|
||||
ssh: []const u8 = "ssh",
|
||||
|
||||
/// When true, print verbose output to stderr.
|
||||
verbose: bool = false,
|
||||
|
||||
/// Arguments passed through to `ssh` verbatim. Populated by
|
||||
/// `parseManuallyHook` when we reach the first non-flag argument (or
|
||||
/// an explicit `--`).
|
||||
_ssh_args: std.ArrayList([]const u8) = .empty,
|
||||
|
||||
/// Enables arg parsing diagnostics so unknown flags become
|
||||
/// diagnostics rather than fatal errors.
|
||||
_diagnostics: diagnostics.DiagnosticList = .{},
|
||||
|
||||
pub fn deinit(self: *Options) void {
|
||||
if (self._arena) |arena| arena.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Enables `-h` and `--help` to work.
|
||||
pub fn help(_: Options) !void {
|
||||
return Action.help_error;
|
||||
}
|
||||
|
||||
/// Manual parse hook. For each argument:
|
||||
/// - If it's a literal `--`, consume everything after it as ssh
|
||||
/// args and stop parsing.
|
||||
/// - If it doesn't start with `--`, this is the start of the ssh
|
||||
/// argv. Consume this arg and everything after as ssh args and
|
||||
/// stop parsing.
|
||||
/// - Otherwise (a `--foo` arg), return true so the generic parser
|
||||
/// handles it as one of our own flags.
|
||||
pub fn parseManuallyHook(
|
||||
self: *Options,
|
||||
alloc: Allocator,
|
||||
arg: []const u8,
|
||||
iter: anytype,
|
||||
) Allocator.Error!bool {
|
||||
if (std.mem.eql(u8, arg, "--")) {
|
||||
while (iter.next()) |rest| {
|
||||
try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!std.mem.startsWith(u8, arg, "--")) {
|
||||
try self._ssh_args.append(alloc, try alloc.dupe(u8, arg));
|
||||
while (iter.next()) |rest| {
|
||||
try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/// Wrap `ssh` to automatically configure Ghostty terminal integration on
|
||||
/// remote hosts.
|
||||
///
|
||||
/// Any arguments that aren't recognized as `+ssh` flags are passed to
|
||||
/// the real `ssh` binary unchanged. You can use `--` as an explicit
|
||||
/// disambiguator if needed, though it's almost never required: `ssh`
|
||||
/// has no long flags, and `+ssh` defines no short flags, so there's
|
||||
/// nothing to collide.
|
||||
///
|
||||
/// This is typically called via Ghostty's shell integration. When
|
||||
/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`,
|
||||
/// each shell defines an `ssh` function that runs:
|
||||
///
|
||||
/// ghostty +ssh <flags> -- "$@"
|
||||
///
|
||||
/// You can also run `ghostty +ssh` directly, or alias it yourself (e.g.
|
||||
/// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell
|
||||
/// integration.
|
||||
///
|
||||
/// `+ssh` performs up to two pieces of setup before launching `ssh`:
|
||||
///
|
||||
/// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to
|
||||
/// `xterm-256color` and requests `SendEnv` forwarding of
|
||||
/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the
|
||||
/// remote shell can still detect that it's running inside Ghostty.
|
||||
/// The remote `sshd_config` must list these in `AcceptEnv` for
|
||||
/// forwarding to succeed.
|
||||
///
|
||||
/// 2. **Terminfo install** (`--terminfo`). On the first connection to a
|
||||
/// given destination, installs Ghostty's terminfo entry on the remote
|
||||
/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a
|
||||
/// shared `ControlMaster` connection. Successful installs are cached
|
||||
/// (see `ghostty +ssh-cache`) so subsequent connections skip this
|
||||
/// step. When terminfo is successfully installed or already cached,
|
||||
/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`.
|
||||
///
|
||||
/// If `--terminfo` install fails (e.g. `tic` not available on the
|
||||
/// remote, filesystem permissions), a warning is logged and the
|
||||
/// connection continues with `TERM=xterm-256color`.
|
||||
///
|
||||
/// Flags:
|
||||
///
|
||||
/// * `--forward-env=<bool>`: Enable `TERM` / `SendEnv` environment
|
||||
/// forwarding. Default: `true`.
|
||||
///
|
||||
/// * `--terminfo=<bool>`: Enable automatic terminfo install on first
|
||||
/// connection. Default: `true`.
|
||||
///
|
||||
/// * `--cache=<bool>`: Use the terminfo install cache. Default: `true`.
|
||||
/// When `false`, both the cache read (skip-if-installed) and the
|
||||
/// cache write (record-on-success) are bypassed, and every
|
||||
/// connection performs the install. To one-shot reinstall a single
|
||||
/// host while keeping the cache in use, prefer `ghostty +ssh-cache
|
||||
/// --remove=<host>` followed by a normal connection.
|
||||
///
|
||||
/// * `--ssh=<path>`: Path to the `ssh` binary to execute. Default: the
|
||||
/// first `ssh` found on `PATH`.
|
||||
///
|
||||
/// * `--verbose`: Print +ssh status lines to stderr, and surface
|
||||
/// remote stderr during the terminfo install.
|
||||
///
|
||||
/// Examples:
|
||||
///
|
||||
/// # Basic invocation using defaults:
|
||||
/// ghostty +ssh user@example.com
|
||||
///
|
||||
/// # Forward Ghostty env vars but skip the terminfo install:
|
||||
/// ghostty +ssh --terminfo=false user@example.com
|
||||
///
|
||||
/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged:
|
||||
/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com
|
||||
///
|
||||
/// # Use `--` explicitly if your ssh args might collide with our flags:
|
||||
/// ghostty +ssh -- --some-rare-ssh-arg user@example.com
|
||||
///
|
||||
/// Pass `--verbose` to see what `+ssh` is doing. For cache inspection
|
||||
/// and management, see `ghostty +ssh-cache`.
|
||||
///
|
||||
/// Available since: 1.4.0
|
||||
pub fn run(alloc_gpa: Allocator) !u8 {
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
|
||||
{
|
||||
var iter = try cli_args.argsIterator(alloc_gpa);
|
||||
defer iter.deinit();
|
||||
try cli_args.parse(Options, alloc_gpa, &opts, &iter);
|
||||
}
|
||||
|
||||
var stderr_buffer: [1024]u8 = undefined;
|
||||
var stderr_file: std.fs.File = .stderr();
|
||||
var stderr_writer = stderr_file.writer(&stderr_buffer);
|
||||
const stderr = &stderr_writer.interface;
|
||||
|
||||
// Any diagnostic from the arg parser is an unknown flag or bad
|
||||
// value. Reject loudly — silently forwarding `--typo` to ssh would
|
||||
// produce confusing downstream errors.
|
||||
if (!opts._diagnostics.empty()) {
|
||||
for (opts._diagnostics.items()) |diag| {
|
||||
if (diag.key.len > 0) {
|
||||
stderr.print(
|
||||
"Error: unknown flag `--{s}`.\n",
|
||||
.{diag.key},
|
||||
) catch {};
|
||||
} else {
|
||||
stderr.print("Error: {s}\n", .{diag.message}) catch {};
|
||||
}
|
||||
}
|
||||
stderr.print("\n{s}", .{usage}) catch {};
|
||||
stderr.flush() catch {};
|
||||
return 2;
|
||||
}
|
||||
|
||||
const result = runInner(alloc_gpa, &opts, stderr);
|
||||
|
||||
stderr.flush() catch {};
|
||||
return result;
|
||||
}
|
||||
|
||||
fn runInner(
|
||||
gpa: Allocator,
|
||||
opts: *const Options,
|
||||
stderr: *std.Io.Writer,
|
||||
) !u8 {
|
||||
var arena = ArenaAllocator.init(gpa);
|
||||
defer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
if (opts._ssh_args.items.len == 0) {
|
||||
try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage});
|
||||
return 2;
|
||||
}
|
||||
|
||||
const session: struct {
|
||||
term: []const u8,
|
||||
to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null,
|
||||
} = session: {
|
||||
if (!opts.terminfo) break :session .{ .term = "xterm-256color" };
|
||||
|
||||
const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse {
|
||||
warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{});
|
||||
break :session .{ .term = "xterm-256color" };
|
||||
};
|
||||
|
||||
const cache: ?DiskCache = if (opts.cache) cache: {
|
||||
const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| {
|
||||
warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err});
|
||||
break :session .{ .term = "xterm-256color" };
|
||||
};
|
||||
break :cache .{ .path = path };
|
||||
} else null;
|
||||
|
||||
if (cache) |c| {
|
||||
if (c.contains(alloc, dest) catch false) {
|
||||
verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest});
|
||||
break :session .{ .term = "xterm-ghostty" };
|
||||
} else {
|
||||
verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest});
|
||||
}
|
||||
} else {
|
||||
verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest});
|
||||
}
|
||||
|
||||
stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {};
|
||||
stderr.flush() catch {};
|
||||
|
||||
installRemoteTerminfo(alloc, opts, stderr) catch |err| {
|
||||
warnPrint(stderr, "failed to install terminfo: {}", .{err});
|
||||
break :session .{ .term = "xterm-256color" };
|
||||
};
|
||||
break :session .{
|
||||
.term = "xterm-ghostty",
|
||||
.to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null,
|
||||
};
|
||||
};
|
||||
|
||||
// Build the full argv: [ssh, ...our opts, ...user args]
|
||||
const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: {
|
||||
const set_term = try std.fmt.allocPrint(
|
||||
alloc,
|
||||
"SetEnv=TERM={s}",
|
||||
.{session.term},
|
||||
);
|
||||
break :env_opts &.{
|
||||
"-o", set_term,
|
||||
"-o", "SendEnv=COLORTERM",
|
||||
"-o", "SendEnv=TERM_PROGRAM",
|
||||
"-o", "SendEnv=TERM_PROGRAM_VERSION",
|
||||
};
|
||||
} else &.{};
|
||||
const argv = try std.mem.concat(alloc, []const u8, &.{
|
||||
&.{opts.ssh},
|
||||
env_opts,
|
||||
opts._ssh_args.items,
|
||||
});
|
||||
verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
|
||||
|
||||
const exit_code = childExec(alloc, argv) catch |err| {
|
||||
try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err });
|
||||
return 1;
|
||||
};
|
||||
verbosePrint(opts, stderr, "exit: {d}", .{exit_code});
|
||||
|
||||
// Attempt to cache (if needed) on a successful ssh execution.
|
||||
if (exit_code == 0) if (session.to_cache) |entry| {
|
||||
if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| {
|
||||
verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest});
|
||||
} else |err| {
|
||||
log.debug("cache add failed for '{s}': {}", .{ entry.dest, err });
|
||||
}
|
||||
};
|
||||
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
/// Log to `.ssh` and, if `--verbose`, also print to stderr.
|
||||
fn verbosePrint(
|
||||
opts: *const Options,
|
||||
stderr: *std.Io.Writer,
|
||||
comptime fmt: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
log.debug(fmt, args);
|
||||
if (!opts.verbose) return;
|
||||
stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return;
|
||||
stderr.flush() catch return;
|
||||
}
|
||||
|
||||
/// Log a warning and also print a `Warning: <msg>` line to stderr.
|
||||
fn warnPrint(
|
||||
stderr: *std.Io.Writer,
|
||||
comptime fmt: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
log.warn(fmt, args);
|
||||
stderr.print("Warning: " ++ fmt ++ "\n", args) catch return;
|
||||
stderr.flush() catch return;
|
||||
}
|
||||
|
||||
/// Space-joined items, formattable as `{f}`.
|
||||
const Joined = struct {
|
||||
items: []const []const u8,
|
||||
|
||||
pub fn format(self: Joined, writer: *std.Io.Writer) !void {
|
||||
for (self.items, 0..) |a, i| {
|
||||
if (i > 0) try writer.writeByte(' ');
|
||||
try writer.writeAll(a);
|
||||
}
|
||||
}
|
||||
|
||||
test {
|
||||
const testing = std.testing;
|
||||
var buf: [128]u8 = undefined;
|
||||
{
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{f}", .{Joined{ .items = &.{} }});
|
||||
try testing.expectEqualStrings("", buf[0..w.end]);
|
||||
}
|
||||
{
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{f}", .{Joined{ .items = &.{"only"} }});
|
||||
try testing.expectEqualStrings("only", buf[0..w.end]);
|
||||
}
|
||||
{
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }});
|
||||
try testing.expectEqualStrings("a b c", buf[0..w.end]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void {
|
||||
switch (term) {
|
||||
.Exited => |rc| if (rc != 0) {
|
||||
log.warn("{s} exited with non-zero status: {d}", .{ label, rc });
|
||||
return error.ChildFailed;
|
||||
},
|
||||
else => {
|
||||
log.warn("{s} terminated abnormally: {}", .{ label, term });
|
||||
return error.ChildFailed;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `ssh -G <args>` and parse the output for `user` and `hostname`.
|
||||
/// Returns the resolved `user@hostname`, or null if the destination
|
||||
/// could not be resolved.
|
||||
fn resolveDestination(
|
||||
alloc: Allocator,
|
||||
ssh: []const u8,
|
||||
args: []const []const u8,
|
||||
) ?[]const u8 {
|
||||
const argv = std.mem.concat(alloc, []const u8, &.{
|
||||
&.{ ssh, "-G" },
|
||||
args,
|
||||
}) catch return null;
|
||||
const result = std.process.Child.run(.{
|
||||
.allocator = alloc,
|
||||
.argv = argv,
|
||||
}) catch |err| {
|
||||
log.warn("ssh -G spawn failed: {}", .{err});
|
||||
return null;
|
||||
};
|
||||
checkExit(result.term, "ssh -G") catch return null;
|
||||
return parseDestination(alloc, result.stdout);
|
||||
}
|
||||
|
||||
/// Parse `ssh -G` output for `user` and `hostname` and return the
|
||||
/// formatted `user@hostname`. Returns null if either key is missing
|
||||
/// or formatting fails.
|
||||
fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 {
|
||||
var user: []const u8 = "";
|
||||
var host: []const u8 = "";
|
||||
var it = std.mem.tokenizeScalar(u8, stdout, '\n');
|
||||
while (it.next()) |line| {
|
||||
const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue;
|
||||
const key = line[0..space];
|
||||
const value = line[space + 1 ..];
|
||||
if (std.mem.eql(u8, key, "user")) {
|
||||
user = value;
|
||||
} else if (std.mem.eql(u8, key, "hostname")) {
|
||||
host = value;
|
||||
}
|
||||
if (user.len > 0 and host.len > 0) break;
|
||||
}
|
||||
|
||||
if (user.len == 0) {
|
||||
log.warn("ssh -G output missing user", .{});
|
||||
return null;
|
||||
}
|
||||
if (host.len == 0) {
|
||||
log.warn("ssh -G output missing hostname", .{});
|
||||
return null;
|
||||
}
|
||||
|
||||
return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null;
|
||||
}
|
||||
|
||||
/// Install Ghostty's terminfo on the remote host over a short-lived SSH
|
||||
/// ControlMaster connection. The master tears down with the client
|
||||
/// (`ControlPersist=no`) so no socket lingers.
|
||||
fn installRemoteTerminfo(
|
||||
alloc: Allocator,
|
||||
opts: *const Options,
|
||||
stderr: *std.Io.Writer,
|
||||
) !void {
|
||||
var buf: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer buf.deinit();
|
||||
try ghostty_terminfo.encode(&buf.writer);
|
||||
const terminfo = buf.written();
|
||||
|
||||
// ControlPath is in TMPDIR with a short, random basename. ssh uses
|
||||
// ControlPath as the bind address for a Unix domain socket; macOS
|
||||
// limits sockaddr_un.sun_path to ~104 bytes, so keeping the path
|
||||
// short leaves margin.
|
||||
const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-");
|
||||
const control_path_opt = try std.fmt.allocPrint(
|
||||
alloc,
|
||||
"ControlPath={s}",
|
||||
.{control_path},
|
||||
);
|
||||
|
||||
// Under --verbose, let remote stderr through (the `tic` step is
|
||||
// the most common failure source) and inherit ssh's stderr so it
|
||||
// reaches the user's terminal. Other steps stay quiet either way.
|
||||
const remote_script = if (opts.verbose)
|
||||
\\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
\\command -v tic >/dev/null 2>&1 || exit 1
|
||||
\\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0
|
||||
\\exit 1
|
||||
else
|
||||
\\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
\\command -v tic >/dev/null 2>&1 || exit 1
|
||||
\\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
\\exit 1
|
||||
;
|
||||
|
||||
// Set up an SSH ControlMaster scoped to this single install:
|
||||
// - ControlMaster=yes makes our client also act as the master,
|
||||
// so `infocmp | ssh tic` runs over a single connection.
|
||||
// - ControlPersist=no tears the master down when our client
|
||||
// exits; no socket lingers on the remote side.
|
||||
const argv = try std.mem.concat(alloc, []const u8, &.{
|
||||
&.{opts.ssh},
|
||||
&.{
|
||||
"-o", "ControlMaster=yes",
|
||||
"-o", "ControlPersist=no",
|
||||
"-o", control_path_opt,
|
||||
},
|
||||
opts._ssh_args.items,
|
||||
&.{remote_script},
|
||||
});
|
||||
verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
|
||||
|
||||
var child: std.process.Child = .init(argv, alloc);
|
||||
child.stdin_behavior = .Pipe;
|
||||
child.stdout_behavior = .Ignore;
|
||||
child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore;
|
||||
|
||||
child.spawn() catch |err| {
|
||||
log.warn("terminfo install spawn failed: {}", .{err});
|
||||
return error.InstallFailed;
|
||||
};
|
||||
|
||||
if (child.stdin) |stdin| {
|
||||
stdin.writeAll(terminfo) catch {};
|
||||
stdin.close();
|
||||
child.stdin = null;
|
||||
}
|
||||
|
||||
const term = child.wait() catch |err| {
|
||||
log.warn("terminfo install wait failed: {}", .{err});
|
||||
return error.InstallFailed;
|
||||
};
|
||||
checkExit(term, "terminfo install") catch return error.InstallFailed;
|
||||
}
|
||||
|
||||
/// Returns `128 + signum` for signal-killed children, matching shell convention.
|
||||
fn childExec(alloc: Allocator, argv: []const []const u8) !u8 {
|
||||
var child: std.process.Child = .init(argv, alloc);
|
||||
child.stdin_behavior = .Inherit;
|
||||
child.stdout_behavior = .Inherit;
|
||||
child.stderr_behavior = .Inherit;
|
||||
|
||||
try child.spawn();
|
||||
const term = try child.wait();
|
||||
return switch (term) {
|
||||
.Exited => |rc| rc,
|
||||
.Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))),
|
||||
.Stopped, .Unknown => 1,
|
||||
};
|
||||
}
|
||||
|
||||
fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void {
|
||||
var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line);
|
||||
defer iter.deinit();
|
||||
try cli_args.parse(Options, alloc, opts, &iter);
|
||||
}
|
||||
|
||||
test "parseManuallyHook: bare destination starts ssh args" {
|
||||
const testing = std.testing;
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com");
|
||||
try testing.expectEqual(false, opts.terminfo);
|
||||
try testing.expectEqual(true, opts.@"forward-env");
|
||||
try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len);
|
||||
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]);
|
||||
}
|
||||
|
||||
test "parseManuallyHook: short ssh flags pass through verbatim" {
|
||||
const testing = std.testing;
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com");
|
||||
try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len);
|
||||
try testing.expectEqualStrings("-p", opts._ssh_args.items[0]);
|
||||
try testing.expectEqualStrings("2222", opts._ssh_args.items[1]);
|
||||
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]);
|
||||
}
|
||||
|
||||
test "parseManuallyHook: explicit -- separator" {
|
||||
const testing = std.testing;
|
||||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
try parseTestArgs(
|
||||
testing.allocator,
|
||||
&opts,
|
||||
"--verbose -- --some-rare-ssh-arg user@example.com",
|
||||
);
|
||||
try testing.expectEqual(true, opts.verbose);
|
||||
try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len);
|
||||
try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]);
|
||||
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]);
|
||||
}
|
||||
|
||||
test "parseDestination: typical ssh -G output" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout =
|
||||
\\user alice
|
||||
\\hostname example.com
|
||||
\\port 22
|
||||
\\identityfile ~/.ssh/id_ed25519
|
||||
\\
|
||||
;
|
||||
const result = parseDestination(arena.allocator(), stdout);
|
||||
try testing.expectEqualStrings("alice@example.com", result.?);
|
||||
}
|
||||
|
||||
test "parseDestination: hostname before user" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout =
|
||||
\\hostname example.com
|
||||
\\port 22
|
||||
\\user alice
|
||||
\\
|
||||
;
|
||||
const result = parseDestination(arena.allocator(), stdout);
|
||||
try testing.expectEqualStrings("alice@example.com", result.?);
|
||||
}
|
||||
|
||||
test "parseDestination: missing hostname returns null" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout = "user alice\nport 22\n";
|
||||
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
|
||||
}
|
||||
|
||||
test "parseDestination: missing user returns null" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout = "hostname example.com\nport 22\n";
|
||||
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
|
||||
}
|
||||
|
||||
test "parseDestination: empty input returns null" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), ""));
|
||||
}
|
||||
|
||||
test "parseDestination: IPv6 hostname" {
|
||||
const testing = std.testing;
|
||||
var arena = ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const stdout = "user alice\nhostname ::1\n";
|
||||
const result = parseDestination(arena.allocator(), stdout);
|
||||
try testing.expectEqualStrings("alice@::1", result.?);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ const fs = std.fs;
|
|||
const Allocator = std.mem.Allocator;
|
||||
const args = @import("args.zig");
|
||||
const Action = @import("ghostty.zig").Action;
|
||||
const Duration = @import("../config.zig").Config.Duration;
|
||||
pub const Entry = @import("ssh-cache/Entry.zig");
|
||||
pub const DiskCache = @import("ssh-cache/DiskCache.zig");
|
||||
|
||||
|
|
@ -10,8 +11,7 @@ pub const Options = struct {
|
|||
clear: bool = false,
|
||||
add: ?[]const u8 = null,
|
||||
remove: ?[]const u8 = null,
|
||||
host: ?[]const u8 = null,
|
||||
@"expire-days": ?u32 = null,
|
||||
prune: ?Duration = null,
|
||||
|
||||
pub fn deinit(self: *Options) void {
|
||||
_ = self;
|
||||
|
|
@ -25,27 +25,36 @@ pub const Options = struct {
|
|||
|
||||
/// Manage the SSH terminfo cache for automatic remote host setup.
|
||||
///
|
||||
/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
|
||||
/// Ghostty automatically installs its terminfo on remote hosts. This command
|
||||
/// manages the cache of successful installations to avoid redundant uploads.
|
||||
/// The `+ssh` action installs Ghostty's terminfo on remote hosts and records
|
||||
/// each success in this cache so it doesn't re-upload on later connections.
|
||||
/// (`+ssh` runs automatically from the shell integration when
|
||||
/// `shell-integration-features` includes `ssh-terminfo`.) This command
|
||||
/// inspects and maintains that cache.
|
||||
///
|
||||
/// The cache stores hostnames (or user@hostname combinations) along with timestamps.
|
||||
/// Entries older than the expiration period are automatically removed during cache
|
||||
/// operations. By default, entries never expire.
|
||||
/// The cache stores destinations (a hostname or user@hostname) along with
|
||||
/// timestamps.
|
||||
///
|
||||
/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
|
||||
/// If multiple are specified, one of the actions will be executed but
|
||||
/// it isn't guaranteed which one. This is entirely unsafe so you should split
|
||||
/// multiple actions into separate commands.
|
||||
/// A positional destination queries the cache: `user@hostname` shows that
|
||||
/// exact entry, while a bare `hostname` shows every cached entry for that
|
||||
/// host regardless of user. With no destination and no action, the entire
|
||||
/// cache is listed. A query that matches nothing exits 1.
|
||||
///
|
||||
/// At most one action (`--clear`, `--add`, `--remove`, or `--prune`) may be
|
||||
/// specified, and not together with a positional destination; combining them
|
||||
/// is an error.
|
||||
///
|
||||
/// `--prune` takes a duration with unit suffixes (`s`, `m`, `h`, `d`, `w`,
|
||||
/// `y`) and removes every entry older than it, e.g. `--prune=30d`,
|
||||
/// `--prune=6h`, `--prune=1y`.
|
||||
///
|
||||
/// Examples:
|
||||
/// ghostty +ssh-cache # List all cached hosts
|
||||
/// ghostty +ssh-cache --host=example.com # Check if host is cached
|
||||
/// ghostty +ssh-cache --add=example.com # Manually add host to cache
|
||||
/// ghostty +ssh-cache --add=user@example.com # Add user@host combination
|
||||
/// ghostty +ssh-cache --remove=example.com # Remove host from cache
|
||||
/// ghostty +ssh-cache --clear # Clear entire cache
|
||||
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
|
||||
/// ghostty +ssh-cache # List all cached destinations
|
||||
/// ghostty +ssh-cache user@example.com # Show that destination
|
||||
/// ghostty +ssh-cache example.com # Show all users on that host
|
||||
/// ghostty +ssh-cache --add=user@example.com # Manually add a destination
|
||||
/// ghostty +ssh-cache --remove=user@example.com # Remove a destination
|
||||
/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days
|
||||
/// ghostty +ssh-cache --clear # Clear entire cache
|
||||
pub fn run(alloc_gpa: Allocator) !u8 {
|
||||
var arena = std.heap.ArenaAllocator.init(alloc_gpa);
|
||||
defer arena.deinit();
|
||||
|
|
@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 {
|
|||
var opts: Options = .{};
|
||||
defer opts.deinit();
|
||||
|
||||
{
|
||||
var iter = try args.argsIterator(alloc_gpa);
|
||||
defer iter.deinit();
|
||||
try args.parse(Options, alloc_gpa, &opts, &iter);
|
||||
}
|
||||
|
||||
var stdout_buffer: [1024]u8 = undefined;
|
||||
var stdout_file: std.fs.File = .stdout();
|
||||
var stdout_writer = stdout_file.writer(&stdout_buffer);
|
||||
|
|
@ -70,7 +73,66 @@ pub fn run(alloc_gpa: Allocator) !u8 {
|
|||
var stderr_writer = stderr_file.writer(&stderr_buffer);
|
||||
const stderr = &stderr_writer.interface;
|
||||
|
||||
const result = runInner(alloc, opts, stdout, stderr);
|
||||
// The cache is queried by a positional destination (`user@host` or a
|
||||
// bare `host`). `args.parse` rejects non-`--` tokens, so we lift the
|
||||
// positional out here and parse only the remaining flags. `--host=X`
|
||||
// is accepted as a deprecated spelling of the positional (it was the
|
||||
// original shipped flag name).
|
||||
var query: ?[]const u8 = null;
|
||||
var flags: std.ArrayList([]const u8) = .empty;
|
||||
{
|
||||
var iter = try args.argsIterator(alloc_gpa);
|
||||
defer iter.deinit();
|
||||
while (iter.next()) |arg| {
|
||||
const is_host_flag = std.mem.startsWith(u8, arg, "--host=");
|
||||
if (is_host_flag) {
|
||||
try stderr.print(
|
||||
"Warning: --host is deprecated; pass the destination " ++
|
||||
"directly, e.g. `ghostty +ssh-cache {s}`.\n",
|
||||
.{arg["--host=".len..]},
|
||||
);
|
||||
}
|
||||
const dest: ?[]const u8 = if (is_host_flag)
|
||||
arg["--host=".len..]
|
||||
else if (!std.mem.startsWith(u8, arg, "-"))
|
||||
arg
|
||||
else
|
||||
null;
|
||||
|
||||
if (dest) |d| {
|
||||
if (query != null) {
|
||||
try stderr.print(
|
||||
"Error: only one destination may be specified.\n",
|
||||
.{},
|
||||
);
|
||||
stderr.flush() catch {};
|
||||
return 2;
|
||||
}
|
||||
query = try alloc.dupe(u8, d);
|
||||
} else {
|
||||
try flags.append(alloc, try alloc.dupe(u8, arg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var iter = args.sliceIterator(flags.items);
|
||||
args.parse(Options, alloc_gpa, &opts, &iter) catch |err| switch (err) {
|
||||
error.InvalidField => {
|
||||
try stderr.print("Error: unknown flag.\n", .{});
|
||||
stderr.flush() catch {};
|
||||
return 2;
|
||||
},
|
||||
error.InvalidValue, error.ValueRequired => {
|
||||
try stderr.print("Error: invalid flag value.\n", .{});
|
||||
stderr.flush() catch {};
|
||||
return 2;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
}
|
||||
|
||||
const result = runInner(alloc, opts, query, stdout, stderr);
|
||||
|
||||
// Flushing *shouldn't* fail but...
|
||||
stdout.flush() catch {};
|
||||
|
|
@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 {
|
|||
pub fn runInner(
|
||||
alloc: Allocator,
|
||||
opts: Options,
|
||||
query: ?[]const u8,
|
||||
stdout: *std.Io.Writer,
|
||||
stderr: *std.Io.Writer,
|
||||
) !u8 {
|
||||
// At most one action may be specified, and a query (positional
|
||||
// destination) is itself an action.
|
||||
const action_count =
|
||||
@as(usize, @intFromBool(opts.clear)) +
|
||||
@intFromBool(opts.add != null) +
|
||||
@intFromBool(opts.remove != null) +
|
||||
@intFromBool(opts.prune != null) +
|
||||
@intFromBool(query != null);
|
||||
if (action_count > 1) {
|
||||
try stderr.print(
|
||||
"Error: only one of a destination, --clear, --add, --remove, " ++
|
||||
"or --prune may be specified.\n",
|
||||
.{},
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Setup our disk cache to the standard location
|
||||
const cache_path = try DiskCache.defaultPath(alloc, "ghostty");
|
||||
const cache: DiskCache = .{ .path = cache_path };
|
||||
|
||||
if (opts.clear) {
|
||||
try cache.clear();
|
||||
try stdout.print("Cache cleared.\n", .{});
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.add) |host| {
|
||||
const result = cache.add(alloc, host) catch |err| switch (err) {
|
||||
DiskCache.Error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
DiskCache.Error.CacheIsLocked => {
|
||||
try stderr.print("Error: Cache is busy, try again\n", .{});
|
||||
return 1;
|
||||
if (opts.add) |dest| {
|
||||
cache.add(alloc, dest, std.time.timestamp()) catch |err| switch (err) {
|
||||
error.InvalidCacheKey => {
|
||||
try stderr.print(
|
||||
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
|
||||
.{dest},
|
||||
);
|
||||
return 2;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to add '{s}' to cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
.{ dest, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
switch (result) {
|
||||
.added => try stdout.print("Added '{s}' to cache.\n", .{host}),
|
||||
.updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}),
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.remove) |host| {
|
||||
cache.remove(alloc, host) catch |err| switch (err) {
|
||||
DiskCache.Error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
DiskCache.Error.CacheIsLocked => {
|
||||
try stderr.print("Error: Cache is busy, try again\n", .{});
|
||||
return 1;
|
||||
if (opts.remove) |dest| {
|
||||
const removed = cache.remove(alloc, dest) catch |err| switch (err) {
|
||||
error.InvalidCacheKey => {
|
||||
try stderr.print(
|
||||
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
|
||||
.{dest},
|
||||
);
|
||||
return 2;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to remove '{s}' from cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
.{ dest, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
try stdout.print("Removed '{s}' from cache.\n", .{host});
|
||||
// Silence on success; a no-op removal is an error (exit 1).
|
||||
if (!removed) {
|
||||
try stderr.print("Error: '{s}' is not in the cache.\n", .{dest});
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.host) |host| {
|
||||
const cached = cache.contains(alloc, host) catch |err| switch (err) {
|
||||
error.HostnameIsInvalid => {
|
||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
||||
return 1;
|
||||
},
|
||||
else => {
|
||||
try stderr.print(
|
||||
"Error: Unable to check host '{s}' in cache. Error: {}\n",
|
||||
.{ host, err },
|
||||
);
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
|
||||
if (cached) {
|
||||
try stdout.print(
|
||||
"'{s}' has Ghostty terminfo installed.\n",
|
||||
.{host},
|
||||
if (opts.prune) |max_age| {
|
||||
const max_age_s = max_age.duration / std.time.ns_per_s;
|
||||
if (max_age_s == 0) {
|
||||
try stderr.print(
|
||||
"Error: --prune requires a duration of at least one second.\n",
|
||||
.{},
|
||||
);
|
||||
return 0;
|
||||
} else {
|
||||
try stdout.print(
|
||||
"'{s}' does not have Ghostty terminfo installed.\n",
|
||||
.{host},
|
||||
);
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
const pruned = cache.prune(alloc, max_age_s) catch |err| {
|
||||
try stderr.print("Error: Unable to prune cache. Error: {}\n", .{err});
|
||||
return 1;
|
||||
};
|
||||
try stdout.print("Pruned cache entries: {d}\n", .{pruned});
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Default action: list all hosts
|
||||
var entries = try cache.list(alloc);
|
||||
defer DiskCache.deinitEntries(alloc, &entries);
|
||||
|
||||
// A positional query filters the listing: an exact `user@host` match,
|
||||
// or every entry on a bare `host`.
|
||||
if (query) |q| {
|
||||
if (!DiskCache.isValidCacheKey(q)) {
|
||||
try stderr.print(
|
||||
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
|
||||
.{q},
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
var matches: std.StringHashMap(Entry) = .init(alloc);
|
||||
defer matches.deinit();
|
||||
var iter = entries.iterator();
|
||||
while (iter.next()) |kv| {
|
||||
const key = kv.key_ptr.*;
|
||||
if (matchesQuery(key, q)) try matches.put(key, kv.value_ptr.*);
|
||||
}
|
||||
|
||||
if (matches.count() == 0) return 1;
|
||||
try listEntries(alloc, &matches, stdout);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// List all destinations by default.
|
||||
try listEntries(alloc, &entries, stdout);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -187,10 +272,7 @@ fn listEntries(
|
|||
entries: *const std.StringHashMap(Entry),
|
||||
writer: *std.Io.Writer,
|
||||
) !void {
|
||||
if (entries.count() == 0) {
|
||||
try writer.print("No hosts in cache.\n", .{});
|
||||
return;
|
||||
}
|
||||
if (entries.count() == 0) return;
|
||||
|
||||
// Sort entries by hostname for consistent output
|
||||
var items: std.ArrayList(Entry) = .empty;
|
||||
|
|
@ -207,22 +289,200 @@ fn listEntries(
|
|||
}
|
||||
}.lessThan);
|
||||
|
||||
try writer.print("Cached hosts ({d}):\n", .{items.items.len});
|
||||
const now = std.time.timestamp();
|
||||
|
||||
// Align the timestamp column by padding destinations to the widest.
|
||||
var widest: usize = 0;
|
||||
for (items.items) |entry| {
|
||||
const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day);
|
||||
if (age_days == 0) {
|
||||
try writer.print(" {s} (today)\n", .{entry.hostname});
|
||||
} else if (age_days == 1) {
|
||||
try writer.print(" {s} (yesterday)\n", .{entry.hostname});
|
||||
} else {
|
||||
try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days });
|
||||
}
|
||||
widest = @max(widest, entry.hostname.len);
|
||||
}
|
||||
|
||||
const now = std.time.timestamp();
|
||||
for (items.items) |entry| {
|
||||
try writer.print("{s}", .{entry.hostname});
|
||||
try writer.splatByteAll(' ', widest - entry.hostname.len + 2);
|
||||
|
||||
var iso_buf: [20]u8 = undefined;
|
||||
var age_buf: [32]u8 = undefined;
|
||||
try writer.print("{s} ({s})\n", .{
|
||||
formatTimestamp(&iso_buf, entry.timestamp),
|
||||
relativeAge(&age_buf, now, entry.timestamp),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cache `key` matches a positional `query`. A `user@host` query
|
||||
/// (containing `@`) matches one exact key; a bare `host` query matches every
|
||||
/// key on that host regardless of user, comparing against the key's host
|
||||
/// component (everything after its first `@`, or the whole key if userless).
|
||||
fn matchesQuery(key: []const u8, query: []const u8) bool {
|
||||
if (std.mem.indexOfScalar(u8, query, '@') != null) {
|
||||
return std.mem.eql(u8, key, query);
|
||||
}
|
||||
|
||||
const at = std.mem.indexOfScalar(u8, key, '@');
|
||||
const host = if (at) |i| key[i + 1 ..] else key;
|
||||
return std.mem.eql(u8, host, query);
|
||||
}
|
||||
|
||||
test matchesQuery {
|
||||
const testing = std.testing;
|
||||
|
||||
// Exact user@host: only the identical key.
|
||||
try testing.expect(matchesQuery("user@example.com", "user@example.com"));
|
||||
try testing.expect(!matchesQuery("root@example.com", "user@example.com"));
|
||||
try testing.expect(!matchesQuery("example.com", "user@example.com"));
|
||||
|
||||
// Bare host: every key on that host, plus a keyless entry for it.
|
||||
try testing.expect(matchesQuery("user@example.com", "example.com"));
|
||||
try testing.expect(matchesQuery("root@example.com", "example.com"));
|
||||
try testing.expect(matchesQuery("example.com", "example.com"));
|
||||
try testing.expect(!matchesQuery("user@other.com", "example.com"));
|
||||
}
|
||||
|
||||
/// Format a Unix timestamp as an ISO-8601 UTC string
|
||||
/// (`YYYY-MM-DDTHH:MM:SSZ`) into `buf`, which must be at least 20 bytes.
|
||||
/// Out-of-range input is clamped so this can't crash on a garbage cache line.
|
||||
fn formatTimestamp(buf: []u8, timestamp: i64) []const u8 {
|
||||
// Clamp to [epoch, last second of 9999-12-31Z]: `std.time.epoch`
|
||||
// accumulates the year in a `u16` (panics beyond that), and the buffer
|
||||
// only fits a 4-digit year.
|
||||
const secs: u64 = @intCast(std.math.clamp(timestamp, 0, 253402300799));
|
||||
|
||||
const epoch = std.time.epoch;
|
||||
const epoch_secs: epoch.EpochSeconds = .{ .secs = secs };
|
||||
const day = epoch_secs.getEpochDay();
|
||||
const year_day = day.calculateYearDay();
|
||||
const month_day = year_day.calculateMonthDay();
|
||||
const ds = epoch_secs.getDaySeconds();
|
||||
return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
|
||||
year_day.year,
|
||||
month_day.month.numeric(),
|
||||
month_day.day_index + 1,
|
||||
ds.getHoursIntoDay(),
|
||||
ds.getMinutesIntoHour(),
|
||||
ds.getSecondsIntoMinute(),
|
||||
}) catch unreachable;
|
||||
}
|
||||
|
||||
test formatTimestamp {
|
||||
const testing = std.testing;
|
||||
var buf: [20]u8 = undefined;
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"2026-05-05T22:49:33Z",
|
||||
formatTimestamp(&buf, 1778021373),
|
||||
);
|
||||
|
||||
// Epoch.
|
||||
try testing.expectEqualStrings(
|
||||
"1970-01-01T00:00:00Z",
|
||||
formatTimestamp(&buf, 0),
|
||||
);
|
||||
|
||||
// Out-of-range inputs clamp instead of overflowing the [20]u8 /
|
||||
// panicking inside std: negatives floor at the epoch, huge values cap
|
||||
// at the last second of year 9999.
|
||||
try testing.expectEqualStrings(
|
||||
"1970-01-01T00:00:00Z",
|
||||
formatTimestamp(&buf, -5),
|
||||
);
|
||||
try testing.expectEqualStrings(
|
||||
"9999-12-31T23:59:59Z",
|
||||
formatTimestamp(&buf, std.math.maxInt(i64)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Format the age of `timestamp` (relative to `now`, both Unix seconds)
|
||||
/// as a coarse relative time into `buf`, e.g. "2w ago". Uses `Duration`'s
|
||||
/// unit vocabulary but keeps only the single largest unit for scannability.
|
||||
/// A non-positive age (timestamp at or after `now`) is "now".
|
||||
fn relativeAge(buf: []u8, now: i64, timestamp: i64) []const u8 {
|
||||
// Saturating so a garbage timestamp can't overflow; clamp at 0 so a
|
||||
// future timestamp becomes a zero age rather than going negative.
|
||||
const age: u64 = @intCast(@max(0, now -| timestamp));
|
||||
if (age == 0) return "now";
|
||||
|
||||
// Round down to the largest unit that fits, so Duration.format emits
|
||||
// only that unit (e.g. 19d -> 2w, 90m -> 1h).
|
||||
const units = [_]u64{
|
||||
365 * std.time.s_per_day, // y
|
||||
std.time.s_per_week, // w
|
||||
std.time.s_per_day, // d
|
||||
std.time.s_per_hour, // h
|
||||
std.time.s_per_min, // m
|
||||
1, // s
|
||||
};
|
||||
const unit = for (units) |u| {
|
||||
if (age >= u) break u;
|
||||
} else 1;
|
||||
|
||||
// Cap the age so `age * ns_per_s` can't overflow u64 (a garbage, e.g.
|
||||
// hugely negative, timestamp otherwise yields an age near i64-max).
|
||||
const max_age = std.math.maxInt(u64) / std.time.ns_per_s;
|
||||
const rounded = @min(age, max_age) / unit * unit;
|
||||
const d: Duration = .{ .duration = rounded * std.time.ns_per_s };
|
||||
return std.fmt.bufPrint(buf, "{f} ago", .{d}) catch unreachable;
|
||||
}
|
||||
|
||||
test relativeAge {
|
||||
const testing = std.testing;
|
||||
var buf: [32]u8 = undefined;
|
||||
const now: i64 = 2_000_000_000; // fixed reference
|
||||
const min = std.time.s_per_min;
|
||||
const hour = std.time.s_per_hour;
|
||||
const day = std.time.s_per_day;
|
||||
|
||||
// Out-of-range timestamps don't crash: a huge future one saturates to
|
||||
// a non-positive age ("now"); a negative one is a large but real age.
|
||||
try testing.expectEqualStrings("now", relativeAge(&buf, now, std.math.maxInt(i64)));
|
||||
try testing.expectEqualStrings("63y ago", relativeAge(&buf, now, -100));
|
||||
|
||||
// A huge age (garbage timestamp) saturates the ns conversion instead of
|
||||
// overflowing; it must not crash and must fit the buffer.
|
||||
try testing.expect(std.mem.endsWith(u8, relativeAge(&buf, std.math.maxInt(i64), 0), " ago"));
|
||||
|
||||
// Future timestamp (clock skew) and same-instant read "now".
|
||||
try testing.expectEqualStrings("now", relativeAge(&buf, now, now + 100));
|
||||
try testing.expectEqualStrings("now", relativeAge(&buf, now, now));
|
||||
|
||||
// Only the single largest unit is kept (smaller units rounded away).
|
||||
try testing.expectEqualStrings("30s ago", relativeAge(&buf, now, now - 30));
|
||||
try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - min));
|
||||
try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - 90)); // 90s -> 1m
|
||||
try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - hour));
|
||||
try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - (hour + 30 * min))); // 1h30m -> 1h
|
||||
try testing.expectEqualStrings("1d ago", relativeAge(&buf, now, now - day));
|
||||
try testing.expectEqualStrings("2w ago", relativeAge(&buf, now, now - 19 * day)); // 19d -> 2w
|
||||
}
|
||||
|
||||
test {
|
||||
_ = DiskCache;
|
||||
_ = Entry;
|
||||
}
|
||||
|
||||
test "runInner rejects multiple actions" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var stdout: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer stdout.deinit();
|
||||
var stderr: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer stderr.deinit();
|
||||
|
||||
// The check runs before any cache access, so it never touches disk.
|
||||
const code = try runInner(alloc, .{
|
||||
.add = "example.com",
|
||||
.remove = "other.com",
|
||||
}, null, &stdout.writer, &stderr.writer);
|
||||
|
||||
try testing.expectEqual(@as(u8, 2), code);
|
||||
try testing.expectEqualStrings("", stdout.written());
|
||||
try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
|
||||
|
||||
// A positional query is itself an action: query + a flag conflicts.
|
||||
stderr.clearRetainingCapacity();
|
||||
const code2 = try runInner(alloc, .{
|
||||
.clear = true,
|
||||
}, "example.com", &stdout.writer, &stderr.writer);
|
||||
try testing.expectEqual(@as(u8, 2), code2);
|
||||
try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Action = @import("../cli.zig").ghostty.Action;
|
||||
const apprt = @import("../apprt.zig");
|
||||
|
||||
pub const Options = struct {
|
||||
/// If set, connect to a custom instance of Ghostty.
|
||||
class: ?[:0]const u8 = null,
|
||||
|
||||
pub fn deinit(self: *Options) void {
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Enables "-h" and "--help" to work.
|
||||
pub fn help(self: Options) !void {
|
||||
_ = self;
|
||||
return Action.help_error;
|
||||
}
|
||||
};
|
||||
|
||||
/// The `+toggle-quick-terminal` command will use native platform IPC to toggle
|
||||
/// the quick terminal in a running instance of Ghostty.
|
||||
///
|
||||
/// If the `--class` flag is not set, the command will try and connect to the
|
||||
/// default running Ghostty instance. Otherwise it will contact a Ghostty
|
||||
/// instance configured with the given `class`.
|
||||
///
|
||||
/// On GTK, D-Bus activation must be properly configured. Ghostty does not need
|
||||
/// to be running, as D-Bus will handle launching a new instance if it is not
|
||||
/// already running.
|
||||
///
|
||||
/// Only supported on GTK.
|
||||
///
|
||||
/// Flags:
|
||||
///
|
||||
/// * `--class=<class>`: If set, connect to a custom instance of Ghostty.
|
||||
/// The class must be a valid GTK application ID.
|
||||
///
|
||||
/// Available since: 1.4.0
|
||||
pub fn run(alloc: Allocator) !u8 {
|
||||
var buf: [256]u8 = undefined;
|
||||
var stderr_writer = std.fs.File.stderr().writer(&buf);
|
||||
const stderr = &stderr_writer.interface;
|
||||
|
||||
if (apprt.App.performIpc(
|
||||
alloc,
|
||||
.detect,
|
||||
.toggle_quick_terminal,
|
||||
{},
|
||||
) catch |err| switch (err) {
|
||||
error.IPCFailed => {
|
||||
return 1;
|
||||
},
|
||||
else => {
|
||||
try stderr.print("Sending the IPC failed: {}\n", .{err});
|
||||
return 1;
|
||||
},
|
||||
}) return 0;
|
||||
|
||||
try stderr.print("+toggle-quick-terminal is not supported on this platform.\n", .{});
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ const string = @import("string.zig");
|
|||
const terminal = struct {
|
||||
const CursorStyle = @import("../terminal/cursor.zig").Style;
|
||||
const color = @import("../terminal/color.zig");
|
||||
const selection_codepoints = @import("../terminal/selection_codepoints.zig");
|
||||
const style = @import("../terminal/style.zig");
|
||||
const x11_color = @import("../terminal/x11_color.zig");
|
||||
};
|
||||
|
|
@ -757,12 +758,12 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
|
|||
/// The null character (U+0000) is always treated as a boundary and does not
|
||||
/// need to be included in this configuration.
|
||||
///
|
||||
/// Default: `` \t'"│`|:;,()[]{}<>$ ``
|
||||
/// Default: ``\t '"│`|:;,()[]{}<>$``
|
||||
///
|
||||
/// To add or remove specific characters, you can set this to a custom value.
|
||||
/// For example, to treat semicolons as part of words:
|
||||
///
|
||||
/// selection-word-chars = " \t'\"│`|:,()[]{}<>$"
|
||||
/// selection-word-chars = "\t '\"│`|:,()[]{}<>$"
|
||||
///
|
||||
/// Available since: 1.3.0
|
||||
@"selection-word-chars": SelectionWordChars = .{},
|
||||
|
|
@ -2890,9 +2891,16 @@ keybind: Keybinds = .{},
|
|||
/// command-palette-entry = title:"Ghostty",description:"Add a little Ghostty to your terminal.",action:"text:\xf0\x9f\x91\xbb"
|
||||
/// ```
|
||||
///
|
||||
/// There are some additional special values that can be specified for
|
||||
/// command-palette-entry:
|
||||
///
|
||||
/// * `command-palette-entry=clear` will clear all command entries. Warning: this
|
||||
/// removes ALL entries up to this point, including the default
|
||||
/// entries. Available since: 1.4.0
|
||||
///
|
||||
/// By default, the command palette is preloaded with most actions that might
|
||||
/// be useful in an interactive setting yet do not have easily accessible or
|
||||
/// memorizable shortcuts. The default entries can be cleared by setting this
|
||||
/// memorizable shortcuts. The default entries can be restored by setting this
|
||||
/// setting to an empty value:
|
||||
///
|
||||
/// ```ini
|
||||
|
|
@ -6177,32 +6185,8 @@ pub const RepeatableString = struct {
|
|||
pub const SelectionWordChars = struct {
|
||||
const Self = @This();
|
||||
|
||||
/// Default boundary characters: ` \t'"│`|:;,()[]{}<>$`
|
||||
const default_codepoints = [_]u21{
|
||||
0, // null
|
||||
' ', // space
|
||||
'\t', // tab
|
||||
'\'', // single quote
|
||||
'"', // double quote
|
||||
'│', // U+2502 box drawing
|
||||
'`', // backtick
|
||||
'|', // pipe
|
||||
':', // colon
|
||||
';', // semicolon
|
||||
',', // comma
|
||||
'(', // left paren
|
||||
')', // right paren
|
||||
'[', // left bracket
|
||||
']', // right bracket
|
||||
'{', // left brace
|
||||
'}', // right brace
|
||||
'<', // less than
|
||||
'>', // greater than
|
||||
'$', // dollar
|
||||
};
|
||||
|
||||
/// The parsed codepoints. Always includes null (U+0000) at index 0.
|
||||
codepoints: []const u21 = &default_codepoints,
|
||||
codepoints: []const u21 = &terminal.selection_codepoints.default_word_boundaries,
|
||||
|
||||
pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void {
|
||||
const value = input orelse return error.ValueRequired;
|
||||
|
|
@ -8777,6 +8761,13 @@ pub const RepeatableCommand = struct {
|
|||
// Unset or empty input clears the list
|
||||
const input = input_ orelse "";
|
||||
if (input.len == 0) {
|
||||
log.info("config has 'command-palette-entry =', using default entries", .{});
|
||||
try self.init(alloc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, input, "clear")) {
|
||||
log.info("config has 'command-palette-entry = clear', all command entries cleared", .{});
|
||||
self.value.clearRetainingCapacity();
|
||||
self.value_c.clearRetainingCapacity();
|
||||
return;
|
||||
|
|
@ -8888,8 +8879,11 @@ pub const RepeatableCommand = struct {
|
|||
try testing.expectEqualStrings("Baz", list.value.items[3].title);
|
||||
try testing.expectEqualStrings("Raspberry Pie", list.value.items[3].description);
|
||||
|
||||
try list.parseCLI(alloc, "");
|
||||
try list.parseCLI(alloc, "clear");
|
||||
try testing.expectEqual(@as(usize, 0), list.value.items.len);
|
||||
|
||||
try list.parseCLI(alloc, "");
|
||||
try testing.expectEqual(inputpkg.command.defaults.len, list.value.items.len);
|
||||
}
|
||||
|
||||
test "RepeatableCommand formatConfig empty" {
|
||||
|
|
@ -9004,7 +8998,7 @@ pub const RepeatableCommand = struct {
|
|||
try list.parseCLI(alloc, "title:Foo,action:ignore");
|
||||
try testing.expectEqual(@as(usize, 1), list.cval().len);
|
||||
|
||||
try list.parseCLI(alloc, "");
|
||||
try list.parseCLI(alloc, "clear");
|
||||
try testing.expectEqual(@as(usize, 0), list.cval().len);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -860,25 +860,26 @@ pub fn SplitTree(comptime V: type) type {
|
|||
var sp = try result.spatial(gpa);
|
||||
defer sp.deinit(gpa);
|
||||
|
||||
// Get the ratio of the split relative to the full grid.
|
||||
const full_ratio = full_ratio: {
|
||||
// Our scale is the amount we need to multiply our individual
|
||||
// ratio by to get the full ratio. Its actually a ratio on its
|
||||
// own but I'm trying to avoid that word: its the ratio of
|
||||
// our spatial width/height to the total.
|
||||
const scale = switch (layout) {
|
||||
.horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width,
|
||||
.vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height,
|
||||
};
|
||||
|
||||
const current = result.nodes[parent_handle.idx()].split.ratio;
|
||||
break :full_ratio current * scale;
|
||||
// Our scale is the amount we need to divide our ratio delta by to
|
||||
// get a delta relative to the split, not the entire grid.
|
||||
// Its actually a ratio on its own but I'm trying to avoid that word:
|
||||
// its the ratio of our spatial width/height to the total.
|
||||
const scale = switch (layout) {
|
||||
.horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width,
|
||||
.vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height,
|
||||
};
|
||||
|
||||
// Set the final new ratio, clamping it to [0, 1]
|
||||
// If the split has spatial width/height 0, resizing by a percentage
|
||||
// of the total grid size doesn't make sense.
|
||||
if (scale == 0) return result;
|
||||
|
||||
// Adjust the old split ratio by the scaled ratio delta.
|
||||
const new_ratio = result.nodes[parent_handle.idx()].split.ratio + (ratio / scale);
|
||||
|
||||
// Set the new ratio, clamping it to [0, 1]
|
||||
result.resizeInPlace(
|
||||
parent_handle,
|
||||
@min(@max(full_ratio + ratio, 0), 1),
|
||||
@min(@max(new_ratio, 0), 1),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
|
@ -2172,6 +2173,155 @@ test "SplitTree: resize" {
|
|||
}
|
||||
}
|
||||
|
||||
test "SplitTree: resize nested split" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var v1: TestTree.View = .{ .label = "A" };
|
||||
var t1: TestTree = try .init(alloc, &v1);
|
||||
defer t1.deinit();
|
||||
var v2: TestTree.View = .{ .label = "B" };
|
||||
var t2: TestTree = try .init(alloc, &v2);
|
||||
defer t2.deinit();
|
||||
var v3: TestTree.View = .{ .label = "C" };
|
||||
var t3: TestTree = try .init(alloc, &v3);
|
||||
defer t3.deinit();
|
||||
|
||||
// A | B vertical
|
||||
var splitAB = try t1.split(
|
||||
alloc,
|
||||
.root, // at root
|
||||
.down, // split down
|
||||
0.5,
|
||||
&t2, // insert t2
|
||||
);
|
||||
defer splitAB.deinit();
|
||||
|
||||
var splitBC = try splitAB.split(
|
||||
alloc,
|
||||
at: {
|
||||
var it = splitAB.iterator();
|
||||
break :at while (it.next()) |entry| {
|
||||
if (std.mem.eql(u8, entry.view.label, "B")) {
|
||||
break entry.handle;
|
||||
}
|
||||
} else return error.NotFound;
|
||||
},
|
||||
.down, // split down
|
||||
0.5,
|
||||
&t3, // insert t3
|
||||
);
|
||||
defer splitBC.deinit();
|
||||
|
||||
{
|
||||
const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(splitBC, .formatDiagram)});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---+
|
||||
\\| |
|
||||
\\| |
|
||||
\\| A |
|
||||
\\| |
|
||||
\\+---+
|
||||
\\+---+
|
||||
\\| B |
|
||||
\\+---+
|
||||
\\+---+
|
||||
\\| C |
|
||||
\\+---+
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
// Resize
|
||||
{
|
||||
var resized = try splitBC.resize(
|
||||
alloc,
|
||||
at: {
|
||||
var it = splitBC.iterator();
|
||||
break :at while (it.next()) |entry| {
|
||||
if (std.mem.eql(u8, entry.view.label, "B")) {
|
||||
break entry.handle;
|
||||
}
|
||||
} else return error.NotFound;
|
||||
},
|
||||
.vertical, // resize down
|
||||
0.125,
|
||||
);
|
||||
defer resized.deinit();
|
||||
const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---+
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| A |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\+---+
|
||||
\\+---+
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| B |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\+---+
|
||||
\\+---+
|
||||
\\| C |
|
||||
\\+---+
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
// Resize the other direction (negative ratio)
|
||||
{
|
||||
var resized = try splitBC.resize(
|
||||
alloc,
|
||||
at: {
|
||||
var it = splitBC.iterator();
|
||||
break :at while (it.next()) |entry| {
|
||||
if (std.mem.eql(u8, entry.view.label, "B")) {
|
||||
break entry.handle;
|
||||
}
|
||||
} else return error.NotFound;
|
||||
},
|
||||
.vertical, // resize up
|
||||
-0.0833,
|
||||
);
|
||||
defer resized.deinit();
|
||||
const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)});
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str,
|
||||
\\+---+
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| A |
|
||||
\\| |
|
||||
\\| |
|
||||
\\| |
|
||||
\\+---+
|
||||
\\+---+
|
||||
\\| B |
|
||||
\\+---+
|
||||
\\+---+
|
||||
\\| |
|
||||
\\| |
|
||||
\\| C |
|
||||
\\| |
|
||||
\\+---+
|
||||
\\
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "SplitTree: clone empty tree" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
|
|
|||
|
|
@ -462,7 +462,8 @@ fn mouseTable(
|
|||
|
||||
{
|
||||
const left_click_point: terminal.point.Coordinate = pt: {
|
||||
const p = surface_mouse.left_click_pin orelse break :pt .{};
|
||||
const p = surface_mouse.selection_gesture.validatedLeftClickPin(&t.screens) orelse
|
||||
break :pt .{};
|
||||
const pt = t.screens.active.pages.pointFromPin(
|
||||
.active,
|
||||
p.*,
|
||||
|
|
@ -495,8 +496,8 @@ fn mouseTable(
|
|||
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
|
||||
cimgui.c.ImGui_Text(
|
||||
"(%dpx, %dpx)",
|
||||
@as(u32, @intFromFloat(surface_mouse.left_click_xpos)),
|
||||
@as(u32, @intFromFloat(surface_mouse.left_click_ypos)),
|
||||
@as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_xpos)),
|
||||
@as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_ypos)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const types = @import("types.zig");
|
|||
const unionpkg = @import("union.zig");
|
||||
|
||||
pub const allocator = @import("allocator.zig");
|
||||
pub const Buffer = types.Buffer;
|
||||
pub const Enum = enumpkg.Enum;
|
||||
pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum;
|
||||
pub const String = types.String;
|
||||
|
|
|
|||
|
|
@ -11,3 +11,9 @@ pub const String = extern struct {
|
|||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Buffer = extern struct {
|
||||
ptr: ?[*]u8 = null,
|
||||
cap: usize = 0,
|
||||
len: usize = 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -209,6 +209,8 @@ comptime {
|
|||
@export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" });
|
||||
@export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" });
|
||||
@export(&c.formatter_free, .{ .name = "ghostty_formatter_free" });
|
||||
@export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" });
|
||||
@export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" });
|
||||
@export(&c.render_state_new, .{ .name = "ghostty_render_state_new" });
|
||||
@export(&c.render_state_update, .{ .name = "ghostty_render_state_update" });
|
||||
@export(&c.render_state_get, .{ .name = "ghostty_render_state_get" });
|
||||
|
|
@ -239,7 +241,27 @@ comptime {
|
|||
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
|
||||
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
|
||||
@export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" });
|
||||
@export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" });
|
||||
@export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" });
|
||||
@export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" });
|
||||
@export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" });
|
||||
@export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" });
|
||||
@export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" });
|
||||
@export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" });
|
||||
@export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" });
|
||||
@export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" });
|
||||
@export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" });
|
||||
@export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" });
|
||||
@export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" });
|
||||
@export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" });
|
||||
@export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" });
|
||||
@export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" });
|
||||
@export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" });
|
||||
@export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" });
|
||||
@export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" });
|
||||
@export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" });
|
||||
@export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" });
|
||||
@export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" });
|
||||
@export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" });
|
||||
@export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" });
|
||||
@export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" });
|
||||
|
|
@ -262,6 +284,11 @@ comptime {
|
|||
@export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" });
|
||||
@export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" });
|
||||
@export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" });
|
||||
@export(&c.tracked_grid_ref_free, .{ .name = "ghostty_tracked_grid_ref_free" });
|
||||
@export(&c.tracked_grid_ref_has_value, .{ .name = "ghostty_tracked_grid_ref_has_value" });
|
||||
@export(&c.tracked_grid_ref_point, .{ .name = "ghostty_tracked_grid_ref_point" });
|
||||
@export(&c.tracked_grid_ref_set, .{ .name = "ghostty_tracked_grid_ref_set" });
|
||||
@export(&c.tracked_grid_ref_snapshot, .{ .name = "ghostty_tracked_grid_ref_snapshot" });
|
||||
@export(&c.build_info, .{ .name = "ghostty_build_info" });
|
||||
@export(&c.type_json, .{ .name = "ghostty_type_json" });
|
||||
@export(&c.alloc_alloc, .{ .name = "ghostty_alloc" });
|
||||
|
|
|
|||
|
|
@ -58,4 +58,5 @@ pub const locales = [_][:0]const u8{
|
|||
"vi",
|
||||
"kk",
|
||||
"be",
|
||||
"eu",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,13 +9,11 @@ const log = std.log.scoped(.@"os-open");
|
|||
/// Open a URL in the default handling application.
|
||||
///
|
||||
/// Any output on stderr is logged as a warning in the application logs.
|
||||
/// Output on stdout is ignored. The allocator is used to buffer the
|
||||
/// log output and may allocate from another thread.
|
||||
/// Output on stdout is ignored.
|
||||
///
|
||||
/// This function is purposely simple for the sake of providing
|
||||
/// some portable way to open URLs. If you are implementing an
|
||||
/// apprt for Ghostty, you should consider doing something special-cased
|
||||
/// for your platform.
|
||||
/// This function is purposely simple for the sake of providing some portable
|
||||
/// way to open URLs. If you are implementing an apprt for Ghostty, you should
|
||||
/// consider doing something special-cased for your platform.
|
||||
pub fn open(
|
||||
alloc: Allocator,
|
||||
kind: apprt.action.OpenUrl.Kind,
|
||||
|
|
@ -44,14 +42,16 @@ pub fn open(
|
|||
else => @compileError("unsupported OS"),
|
||||
};
|
||||
|
||||
// Pipe stdout/stderr so we can collect output from the command.
|
||||
// This must be set before spawning the process.
|
||||
exe.stdout_behavior = .Pipe;
|
||||
// Ignore anything from stdout. This must be set before spawning the
|
||||
// process.
|
||||
exe.stdout_behavior = .Ignore;
|
||||
// Pipe stderr so we can log the stderr from the command. This must be set
|
||||
// before spawning the process.
|
||||
exe.stderr_behavior = .Pipe;
|
||||
|
||||
// In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at
|
||||
// the snap's bundled libraries. Leaking this into child process can
|
||||
// can be problematic, so let's drop it from the env
|
||||
// the snap's bundled libraries. Leaking this into child process can can be
|
||||
// problematic, so let's drop it from the env
|
||||
var snap_env: std.process.EnvMap = if (comptime build_config.snap) blk: {
|
||||
var env = try std.process.getEnvMap(alloc);
|
||||
env.remove("LD_LIBRARY_PATH");
|
||||
|
|
@ -64,34 +64,34 @@ pub fn open(
|
|||
// quickly.
|
||||
try exe.spawn();
|
||||
|
||||
// Create a thread that handles collecting output and reaping
|
||||
// the process. This is done in a separate thread because SOME
|
||||
// open implementations block and some do not. It's easier to just
|
||||
// spawn a thread to handle this so that we never block.
|
||||
const thread = try std.Thread.spawn(.{}, openThread, .{ alloc, exe });
|
||||
// Create a thread that handles collecting output and reaping the process.
|
||||
// This is done in a separate thread because SOME open implementations block
|
||||
// and some do not. It's easier to just spawn a thread to handle this so
|
||||
// that we never block.
|
||||
const thread = try std.Thread.spawn(.{}, openThread, .{exe});
|
||||
thread.detach();
|
||||
}
|
||||
|
||||
fn openThread(alloc: Allocator, exe_: std.process.Child) !void {
|
||||
// 50 KiB is the default value used by std.process.Child.run and should
|
||||
// be enough to get the output we care about.
|
||||
const output_max_size = 50 * 1024;
|
||||
|
||||
var stdout: std.ArrayListUnmanaged(u8) = .{};
|
||||
var stderr: std.ArrayListUnmanaged(u8) = .{};
|
||||
defer {
|
||||
stdout.deinit(alloc);
|
||||
stderr.deinit(alloc);
|
||||
}
|
||||
|
||||
fn openThread(exe_: std.process.Child) void {
|
||||
// Copy the exe so it is non-const. This is necessary because wait()
|
||||
// requires a mutable reference and we can't have one as a thread
|
||||
// param.
|
||||
var exe = exe_;
|
||||
try exe.collectOutput(alloc, &stdout, &stderr, output_max_size);
|
||||
_ = try exe.wait();
|
||||
|
||||
// If we have any stderr output we log it. This makes it easier for
|
||||
// users to debug why some open commands may not work as expected.
|
||||
if (stderr.items.len > 0) log.warn("wait stderr={s}", .{stderr.items});
|
||||
if (exe.stderr) |stderr| {
|
||||
var buffer: [256]u8 = undefined;
|
||||
var stream = stderr.readerStreaming(&buffer);
|
||||
const reader = &stream.interface;
|
||||
while (true) {
|
||||
const line = reader.takeDelimiterExclusive('\n') catch |outer| switch (outer) {
|
||||
error.EndOfStream => break,
|
||||
error.ReadFailed => break,
|
||||
error.StreamTooLong => reader.take(buffer.len) catch |inner| switch (inner) {
|
||||
error.ReadFailed => break,
|
||||
error.EndOfStream => break,
|
||||
},
|
||||
};
|
||||
log.warn("open stderr={s}", .{line});
|
||||
}
|
||||
}
|
||||
_ = exe.wait() catch {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,10 +360,16 @@ fn drainMailbox(self: *Thread) !void {
|
|||
// Visibility affects our QoS class
|
||||
self.setQosClass();
|
||||
|
||||
// If we became visible then we immediately trigger a draw.
|
||||
// We don't need to update frame data because that should
|
||||
// still be happening.
|
||||
if (v) self.drawFrame(false);
|
||||
// If we became visible then we immediately rebuild cells
|
||||
// (renderCallback skips updateFrame while invisible) and draw.
|
||||
if (v) {
|
||||
self.renderer.updateFrame(
|
||||
self.state,
|
||||
self.flags.cursor_blink_visible,
|
||||
) catch |err|
|
||||
log.warn("error rendering on visibility regain err={}", .{err});
|
||||
self.drawFrame(false);
|
||||
}
|
||||
|
||||
// Notify the renderer so it can update any state.
|
||||
self.renderer.setVisible(v);
|
||||
|
|
@ -606,6 +612,10 @@ fn renderCallback(
|
|||
return .disarm;
|
||||
};
|
||||
|
||||
// If we're not visible there's no point spending CPU rebuilding cells —
|
||||
// we'll catch up when the .visible mailbox message flips us back on.
|
||||
if (!t.flags.visible) return .disarm;
|
||||
|
||||
// Update our frame data
|
||||
t.renderer.updateFrame(
|
||||
t.state,
|
||||
|
|
|
|||
|
|
@ -61,12 +61,6 @@ if (eq $E:TERM "xterm-ghostty") {
|
|||
}
|
||||
```
|
||||
|
||||
The [Elvish](https://elv.sh) shell integration is supported by
|
||||
the community and is not officially supported by Ghostty. We distribute
|
||||
it for ease of access and use but do not provide support for it.
|
||||
If you experience issues with the Elvish shell integration, I welcome
|
||||
any contributions to fix them. Thank you!
|
||||
|
||||
### Fish
|
||||
|
||||
For [Fish](https://fishshell.com/), Ghostty prepends to the
|
||||
|
|
|
|||
|
|
@ -115,71 +115,16 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
|
|||
fi
|
||||
|
||||
# SSH Integration
|
||||
#
|
||||
# Wrap `ssh` with `ghostty +ssh` and translate the shell-integration
|
||||
# feature flags into command options.
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
|
||||
function ssh() {
|
||||
builtin local ssh_term ssh_opts
|
||||
ssh_term="xterm-256color"
|
||||
ssh_opts=()
|
||||
|
||||
# Configure environment variables for remote session
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then
|
||||
ssh_opts+=(-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION")
|
||||
fi
|
||||
|
||||
# Install terminfo on remote host if needed
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then
|
||||
builtin local ssh_user ssh_hostname
|
||||
|
||||
while IFS=' ' read -r ssh_key ssh_value; do
|
||||
case "$ssh_key" in
|
||||
user) ssh_user="$ssh_value" ;;
|
||||
hostname) ssh_hostname="$ssh_value" ;;
|
||||
esac
|
||||
[[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break
|
||||
done < <(builtin command ssh -G "$@" 2>/dev/null)
|
||||
|
||||
if [[ -n "$ssh_hostname" ]]; then
|
||||
builtin local ssh_target="${ssh_user}@${ssh_hostname}"
|
||||
|
||||
# Check if terminfo is already cached
|
||||
if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then
|
||||
ssh_term="xterm-ghostty"
|
||||
elif builtin command -v infocmp >/dev/null 2>&1; then
|
||||
builtin local ssh_terminfo ssh_cpath_dir ssh_cpath
|
||||
|
||||
ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null)
|
||||
|
||||
if [[ -n "$ssh_terminfo" ]]; then
|
||||
builtin echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2
|
||||
|
||||
ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$"
|
||||
ssh_cpath="$ssh_cpath_dir/socket"
|
||||
|
||||
if builtin echo "$ssh_terminfo" | builtin command ssh -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" '
|
||||
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
command -v tic >/dev/null 2>&1 || exit 1
|
||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
exit 1
|
||||
' 2>/dev/null; then
|
||||
ssh_term="xterm-ghostty"
|
||||
ssh_opts+=(-o "ControlPath=$ssh_cpath")
|
||||
|
||||
# Cache successful installation
|
||||
"$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true
|
||||
else
|
||||
builtin echo "Warning: Failed to install terminfo." >&2
|
||||
fi
|
||||
else
|
||||
builtin echo "Warning: Could not generate terminfo data." >&2
|
||||
fi
|
||||
else
|
||||
builtin echo "Warning: ghostty command not available for cache management." >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Execute SSH with TERM environment variable
|
||||
TERM="$ssh_term" COLORTERM=truecolor builtin command ssh "${ssh_opts[@]}" "$@"
|
||||
builtin local -a flags
|
||||
flags=()
|
||||
[[ "$GHOSTTY_SHELL_FEATURES" != *ssh-env* ]] && flags+=(--forward-env=false)
|
||||
[[ "$GHOSTTY_SHELL_FEATURES" != *ssh-terminfo* ]] && flags+=(--terminfo=false)
|
||||
"$GHOSTTY_BIN_DIR/ghostty" +ssh "${flags[@]}" -- "$@"
|
||||
}
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -76,80 +76,20 @@
|
|||
(external sudo) $@args
|
||||
}
|
||||
|
||||
# SSH Integration
|
||||
#
|
||||
# Wrap `ssh` with `ghostty +ssh` and translate the shell-integration
|
||||
# feature flags into command options.
|
||||
fn ssh-integration {|@args|
|
||||
var ssh-term = "xterm-256color"
|
||||
var ssh-opts = []
|
||||
|
||||
# Configure environment variables for remote session
|
||||
if (has-value $features ssh-env) {
|
||||
set ssh-opts = (conj $ssh-opts ^
|
||||
-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION")
|
||||
var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty"
|
||||
var flags = []
|
||||
if (not (has-value $features ssh-env)) {
|
||||
set flags = (conj $flags --forward-env=false)
|
||||
}
|
||||
|
||||
if (has-value $features ssh-terminfo) {
|
||||
var ssh-user = ""
|
||||
var ssh-hostname = ""
|
||||
|
||||
# Parse ssh config
|
||||
for line [((external ssh) -G $@args)] {
|
||||
var parts = [(str:fields $line)]
|
||||
if (> (count $parts) 1) {
|
||||
var ssh-key = $parts[0]
|
||||
var ssh-value = $parts[1]
|
||||
if (eq $ssh-key user) {
|
||||
set ssh-user = $ssh-value
|
||||
} elif (eq $ssh-key hostname) {
|
||||
set ssh-hostname = $ssh-value
|
||||
}
|
||||
if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (not-eq $ssh-hostname "") {
|
||||
var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty"
|
||||
var ssh-target = $ssh-user"@"$ssh-hostname
|
||||
|
||||
# Check if terminfo is already cached
|
||||
if (bool ?($ghostty +ssh-cache --host=$ssh-target)) {
|
||||
set ssh-term = "xterm-ghostty"
|
||||
} elif (has-external infocmp) {
|
||||
var ssh-terminfo = ((external infocmp) -0 -x xterm-ghostty 2>/dev/null | slurp)
|
||||
|
||||
if (not-eq $ssh-terminfo "") {
|
||||
echo "Setting up xterm-ghostty terminfo on "$ssh-hostname"..." >&2
|
||||
|
||||
use os
|
||||
var ssh-cpath-dir = (os:temp-dir "ghostty-ssh-"$ssh-user".*")
|
||||
var ssh-cpath = $ssh-cpath-dir"/socket"
|
||||
|
||||
if (bool ?(echo $ssh-terminfo | (external ssh) $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args '
|
||||
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
command -v tic >/dev/null 2>&1 || exit 1
|
||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
exit 1
|
||||
' 2>/dev/null)) {
|
||||
set ssh-term = "xterm-ghostty"
|
||||
set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath)
|
||||
|
||||
# Cache successful installation
|
||||
$ghostty +ssh-cache --add=$ssh-target >/dev/null
|
||||
} else {
|
||||
echo "Warning: Failed to install terminfo." >&2
|
||||
}
|
||||
} else {
|
||||
echo "Warning: Could not generate terminfo data." >&2
|
||||
}
|
||||
} else {
|
||||
echo "Warning: ghostty command not available for cache management." >&2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with [E:TERM = $ssh-term E:COLORTERM = truecolor] {
|
||||
(external ssh) $@ssh-opts $@args
|
||||
if (not (has-value $features ssh-terminfo)) {
|
||||
set flags = (conj $flags --terminfo=false)
|
||||
}
|
||||
$ghostty +ssh $@flags -- $@args
|
||||
}
|
||||
|
||||
defer {
|
||||
|
|
|
|||
|
|
@ -120,84 +120,17 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
|||
end
|
||||
|
||||
# SSH Integration
|
||||
#
|
||||
# Wrap `ssh` with `ghostty +ssh` and translate the shell-integration
|
||||
# feature flags into command options.
|
||||
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
|
||||
if contains ssh-env $features; or contains ssh-terminfo $features
|
||||
function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration"
|
||||
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
|
||||
set -l ssh_term xterm-256color
|
||||
set -l ssh_opts
|
||||
|
||||
# Configure environment variables for remote session
|
||||
if contains ssh-env $features
|
||||
set -a ssh_opts -o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION"
|
||||
end
|
||||
|
||||
# Install terminfo on remote host if needed
|
||||
if contains ssh-terminfo $features
|
||||
set -l ssh_user
|
||||
set -l ssh_hostname
|
||||
|
||||
for line in (command ssh -G $argv 2>/dev/null)
|
||||
set -l parts (string split ' ' -- $line)
|
||||
if test (count $parts) -ge 2
|
||||
switch $parts[1]
|
||||
case user
|
||||
set ssh_user $parts[2]
|
||||
case hostname
|
||||
set ssh_hostname $parts[2]
|
||||
end
|
||||
if test -n "$ssh_user"; and test -n "$ssh_hostname"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if test -n "$ssh_hostname"
|
||||
set -l ssh_target "$ssh_user@$ssh_hostname"
|
||||
|
||||
# Check if terminfo is already cached
|
||||
if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1
|
||||
set ssh_term xterm-ghostty
|
||||
else if command -q infocmp
|
||||
set -l ssh_terminfo
|
||||
set -l ssh_cpath_dir
|
||||
set -l ssh_cpath
|
||||
|
||||
set ssh_terminfo "$(infocmp -0 -x xterm-ghostty 2>/dev/null)"
|
||||
|
||||
if test -n "$ssh_terminfo"
|
||||
echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2
|
||||
|
||||
set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random))
|
||||
set ssh_cpath "$ssh_cpath_dir/socket"
|
||||
|
||||
if echo "$ssh_terminfo" | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv '
|
||||
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
command -v tic >/dev/null 2>&1 || exit 1
|
||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
exit 1
|
||||
' 2>/dev/null
|
||||
set ssh_term xterm-ghostty
|
||||
set -a ssh_opts -o "ControlPath=$ssh_cpath"
|
||||
|
||||
# Cache successful installation
|
||||
if test -x "$GHOSTTY_BIN_DIR/ghostty"
|
||||
"$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true
|
||||
end
|
||||
else
|
||||
echo "Warning: Failed to install terminfo." >&2
|
||||
end
|
||||
else
|
||||
echo "Warning: Could not generate terminfo data." >&2
|
||||
end
|
||||
else
|
||||
echo "Warning: ghostty command not available for cache management." >&2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Execute SSH with TERM environment variable
|
||||
TERM="$ssh_term" COLORTERM=truecolor command ssh $ssh_opts $argv
|
||||
set -l flags
|
||||
contains ssh-env $features; or set -a flags --forward-env=false
|
||||
contains ssh-terminfo $features; or set -a flags --terminfo=false
|
||||
"$GHOSTTY_BIN_DIR/ghostty" +ssh $flags -- $argv
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue