diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d8b9d2c18..f928ed5a5 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,5 +1,10 @@ on: [push, pull_request] name: Nix + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }} + cancel-in-progress: true + jobs: required: name: "Required Checks: Nix" @@ -34,7 +39,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 50892a151..82970a065 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -132,7 +132,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: DeterminateSystems/nix-installer-action@main with: @@ -306,7 +306,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download macOS Artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index a8a7f641f..df73198d1 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -29,7 +29,7 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -66,7 +66,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -81,7 +81,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -104,7 +104,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -127,7 +127,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -159,7 +159,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -186,7 +186,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -217,7 +217,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -356,7 +356,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -451,7 +451,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -583,7 +583,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -635,7 +635,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -767,7 +767,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b6acd385..20f674bab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,11 @@ on: name: Test +# We only want the latest commit to test for any non-main ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }} + cancel-in-progress: true + jobs: required: name: "Required Checks: Test" @@ -69,7 +74,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -112,7 +117,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -145,7 +150,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -179,7 +184,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -222,7 +227,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -258,7 +263,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -287,7 +292,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -320,7 +325,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -366,7 +371,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -404,7 +409,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Trigger Snap workflow run: | @@ -421,7 +426,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -464,7 +469,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -509,7 +514,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -580,7 +585,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Get required Zig version id: zig @@ -627,7 +632,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -675,7 +680,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -710,7 +715,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -737,7 +742,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -774,7 +779,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -804,7 +809,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -832,7 +837,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -859,7 +864,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -886,7 +891,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -913,7 +918,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -940,7 +945,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -974,7 +979,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1001,7 +1006,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1035,7 +1040,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1104,7 +1109,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} needs: test steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 with: bundle: com.mitchellh.ghostty @@ -1123,7 +1128,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1162,7 +1167,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + # uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # # - name: Start SSH # run: | diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index b641c0bc9..ca65c2a21 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,7 +17,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 @@ -62,7 +62,7 @@ jobs: run: nix build .#ghostty - name: Create pull request - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: title: Update iTerm2 colorschemes base: main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7df4b71..b4285f42f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,10 +23,20 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -If PR responses are being generated by an AI, disclose that as well. As a small exception, trivial tab-completion doesn't need to be disclosed, so long as it is limited to single keywords or short phrases. +The submitter must have also tested the pull request on all impacted +platforms, and it's **highly discouraged** to code for an unfamiliar platform +with AI assistance alone: if you only have a macOS machine, do **not** ask AI +to write the equivalent GTK code, and vice versa — someone else with more +expertise will eventually get to it and do it for you. + +Even though using AI to generate responses on a PR is allowed when properly +disclosed, **we do not encourage you to do so**. Often, the positive impact +of genuine, responsive human interaction more than makes up for any language +barrier. ❤️ + An example disclosure: > This PR was written primarily by Claude Code. @@ -36,6 +46,11 @@ Or a more detailed disclosure: > I consulted ChatGPT to understand the codebase but the solution > was fully authored manually by myself. +An example of a **problematic** disclosure (not having tested all platforms): + +> I used Amp to code both macOS and GTK UIs, but I have not yet tested +> the GTK UI as I don't have a Linux setup. + Failure to disclose this is first and foremost rude to the human operators on the other end of the pull request, but it also makes it difficult to determine how much scrutiny to apply to the contribution. @@ -45,10 +60,12 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! -When using AI assistance, we expect contributors to understand the code +When using AI assistance, we expect a fairly high level of accountability +and responsibility from contributors, and expect them to understand the code that is produced and be able to answer critical questions about it. It isn't a maintainers job to review a PR so broken that it requires -significant rework to be acceptable. +significant rework to be acceptable, and we **reserve the right to close +these PRs without hesitation**. Please be respectful to maintainers and disclose AI assistance. @@ -74,22 +91,47 @@ submission. ### I have a bug! / Something isn't working -1. Search the issue tracker and discussions for similar issues. Tip: also - search for [closed issues] and [discussions] — your issue might have already - been fixed! -2. If your issue hasn't been reported already, open an ["Issue Triage" discussion] - and make sure to fill in the template **completely**. They are vital for - maintainers to figure out important details about your setup. Because of - this, please make sure that you _only_ use the "Issue Triage" category for - reporting bugs — thank you! +First, search the issue tracker and discussions for similar issues. Tip: also +search for [closed issues] and [discussions] — your issue might have already +been fixed! + +> [!NOTE] +> +> If there is an _open_ issue or discussion that matches your problem, +> **please do not comment on it unless you have valuable insight to add**. +> +> GitHub has a very _noisy_ set of default notification settings which +> sends an email to _every participant_ in an issue/discussion every time +> someone adds a comment. Instead, use the handy upvote button for discussions, +> and/or emoji reactions on both discussions and issues, which are a visible +> yet non-disruptive way to show your support. + +If your issue hasn't been reported already, open an ["Issue Triage"] discussion +and make sure to fill in the template **completely**. They are vital for +maintainers to figure out important details about your setup. + +> [!WARNING] +> +> A _very_ common mistake is to file a bug report either as a Q&A or a Feature +> Request. **Please don't do this.** Otherwise, maintainers would have to ask +> for your system information again manually, and sometimes they will even ask +> you to create a new discussion because of how few detailed information is +> required for other discussion types compared to Issue Triage. +> +> Because of this, please make sure that you _only_ use the "Issue Triage" +> category for reporting bugs — thank you! [closed issues]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20state%3Aclosed [discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed -["Issue Triage" discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage +["Issue Triage"]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage ### I have an idea for a feature -Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas). +Like bug reports, first search through both issues and discussions and try to +find if your feature has already been requested. Otherwise, open a discussion +in the ["Feature Requests, Ideas"] category. + +["Feature Requests, Ideas"]: https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas ### I've implemented a feature @@ -98,10 +140,28 @@ Open a discussion in the ["Feature Requests, Ideas" category](https://github.com 3. If you want to live dangerously, open a pull request and [hope for the best](#pull-requests-implement-an-issue). -### I have a question +### I have a question which is neither a bug report nor a feature request Open an [Q&A discussion], or join our [Discord Server] and ask away in the -`#help` channel. +`#help` forum channel. + +Do not use the `#terminals` or `#development` channels to ask for help — +those are for general discussion about terminals and Ghostty development +respectively. If you do ask a question there, you will be redirected to +`#help` instead. + +> [!NOTE] +> If your question is about a missing feature, please open a discussion under +> the ["Feature Requests, Ideas"] category. If Ghostty is behaving +> unexpectedly, use the ["Issue Triage"] category. +> +> The "Q&A" category is strictly for other kinds of discussions and do not +> require detailed information unlike the two other categories, meaning that +> maintainers would have to spend the extra effort to ask for basic information +> if you submit a bug report under this category. +> +> Therefore, please **pay attention to the category** before opening +> discussions to save us all some time and energy. Thank you! [Q&A discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=q-a [Discord Server]: https://discord.gg/ghostty diff --git a/build.zig.zon b/build.zig.zon index 993904aec..20cf44141 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", - .hash = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 9ca70c410..cb827e238 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN": { + "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", - "hash": "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 2563f5411..0ec137c70 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN"; + name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz"; - hash = "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 4362c5d36..6b19df24e 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -29,7 +29,7 @@ https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 672fd7a5f..9563f9622 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", - "sha256": "e669975bb77d4a47ac1f22305019569b21ad3967fac2ed12eb39077bddc554b3" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, { "type": "archive", diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift index 9f86a7c2b..08bbcb8d9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift @@ -33,6 +33,7 @@ struct QuickTerminalSize { case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS: self = .pixels(cStruct.value.pixels) default: + assertionFailure() return nil } } diff --git a/src/Surface.zig b/src/Surface.zig index 591ee7220..653178bdc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -155,6 +155,9 @@ command_timer: ?std.time.Instant = null, /// Search state search: ?Search = null, +/// Used to rate limit BEL handling. +last_bell_time: ?std.time.Instant = null, + /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key /// input can be forwarded to the OS for further processing if it @@ -1026,7 +1029,12 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .password_input => |v| try self.passwordInput(v), - .ring_bell => { + .ring_bell => bell: { + const now = std.time.Instant.now() catch unreachable; + if (self.last_bell_time) |last| { + if (now.since(last) < 100 * std.time.ns_per_ms) break :bell; + } + self.last_bell_time = now; _ = self.rt_app.performAction( .{ .surface = self }, .ring_bell, @@ -2584,6 +2592,8 @@ pub fn keyCallback( { // Refresh our link state const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); self.mouseRefreshLinks( pos, self.posToViewport(pos.x, pos.y), @@ -3464,6 +3474,8 @@ fn mouseReport( .five => 65, .six => 66, .seven => 67, + .eight => 128, + .nine => 129, else => return, // unsupported }; } @@ -5020,8 +5032,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .copy_to_clipboard => |format| { - // We can read from the renderer state without holding - // the lock because only we will write to this field. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (self.io.terminal.screens.active.selection) |sel| { try self.copySelectionToClipboards( sel, @@ -5049,8 +5062,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .copy_url_to_clipboard => { // If the mouse isn't over a link, nothing we can do. if (!self.mouse.over_link) return false; - const pos = try self.rt_surface.getCursorPos(); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); if (try self.linkAtPos(pos)) |link_info| { const url_text = switch (link_info[0]) { .open => url_text: { @@ -5426,6 +5441,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ), .select_all => { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { try self.setSelection(s); diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index cc701d7c2..c77579aab 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -43,6 +43,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "inspector-widget" }, .{ .major = 1, .minor = 5, .name = "inspector-window" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 2, .name = "search-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index cc070240c..69576bf00 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -727,6 +727,11 @@ pub const Application = extern struct { .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), + .start_search => Action.startSearch(target), + .end_search => Action.endSearch(target), + .search_total => Action.searchTotal(target, value), + .search_selected => Action.searchSelected(target, value), + // Unimplemented .secure_input, .close_all_windows, @@ -741,10 +746,6 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, - .start_search, - .end_search, - .search_total, - .search_selected, => { log.warn("unimplemented action={}", .{action}); return false; @@ -2341,6 +2342,34 @@ const Action = struct { } } + pub fn startSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(true), + } + } + + pub fn endSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(false), + } + } + + pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchTotal(value.total), + } + } + + pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchSelected(value.selected), + } + } + pub fn setTitle( target: apprt.Target, value: apprt.action.SetTitle, diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig new file mode 100644 index 000000000..4936cd967 --- /dev/null +++ b/src/apprt/gtk/class/search_overlay.zig @@ -0,0 +1,486 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gdk = @import("gdk"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_search_overlay); + +/// The overlay that shows the current size while a surface is resizing. +/// This can be used generically to show pretty much anything with a +/// disappearing overlay, but we have no other use at this point so it +/// is named specifically for what it does. +/// +/// General usage: +/// +/// 1. Add it to an overlay +/// 2. Set the label with `setLabel` +/// 3. Schedule to show it with `schedule` +/// +/// Set any properties to change the behavior. +pub const SearchOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySearchOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const active = struct { + pub const name = "active"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getSearchActive, + .setter = setSearchActive, + }, + ), + }, + ); + }; + + pub const @"search-total" = struct { + pub const name = "search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + u64, + .{ + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchTotal }, + ), + }, + ); + }; + + pub const @"has-search-total" = struct { + pub const name = "has-search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchTotal }, + ), + }, + ); + }; + + pub const @"search-selected" = struct { + pub const name = "search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + u64, + .{ + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchSelected }, + ), + }, + ); + }; + + pub const @"has-search-selected" = struct { + pub const name = "has-search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchSelected }, + ), + }, + ); + }; + + pub const @"halign-target" = struct { + pub const name = "halign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .end, + .accessor = C.privateShallowFieldAccessor("halign_target"), + }, + ); + }; + + pub const @"valign-target" = struct { + pub const name = "valign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .start, + .accessor = C.privateShallowFieldAccessor("valign_target"), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted when the search is stopped (e.g., Escape pressed). + pub const @"stop-search" = struct { + pub const name = "stop-search"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when the search text changes (debounced). + pub const @"search-changed" = struct { + pub const name = "search-changed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{?[*:0]const u8}, + void, + ); + }; + + /// Emitted when navigating to the next match. + pub const @"next-match" = struct { + pub const name = "next-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when navigating to the previous match. + pub const @"previous-match" = struct { + pub const name = "previous-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + /// The search entry widget. + search_entry: *gtk.SearchEntry, + + /// True when a search is active, meaning we should show the overlay. + active: bool = false, + + /// Total number of search matches (null means unknown/none). + search_total: ?usize = null, + + /// Currently selected match index (null means none selected). + search_selected: ?usize = null, + + /// Target horizontal alignment for the overlay. + halign_target: gtk.Align = .end, + + /// Target vertical alignment for the overlay. + valign_target: gtk.Align = .start, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + /// Grab focus on the search entry and select all text. + pub fn grabFocus(self: *Self) void { + const priv = self.private(); + _ = priv.search_entry.as(gtk.Widget).grabFocus(); + + // Select all text in the search entry field. -1 is distance from + // the end, causing the entire text to be selected. + priv.search_entry.as(gtk.Editable).selectRegion(0, -1); + } + + // Set active status, and update search on activation + fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + if (!priv.active and active) { + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + priv.active = active; + } + + /// Set the total number of search matches. + pub fn setSearchTotal(self: *Self, total: ?usize) void { + const priv = self.private(); + const had_total = priv.search_total != null; + if (priv.search_total == total) return; + priv.search_total = total; + self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec); + if (had_total != (total != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec); + } + } + + /// Set the currently selected match index. + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + const priv = self.private(); + const had_selected = priv.search_selected != null; + if (priv.search_selected == selected) return; + priv.search_selected = selected; + self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec); + if (had_selected != (selected != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec); + } + } + + fn getSearchActive(self: *Self) bool { + return self.private().active; + } + + fn getSearchTotal(self: *Self) u64 { + return self.private().search_total orelse 0; + } + + fn getHasSearchTotal(self: *Self) bool { + return self.private().search_total != null; + } + + fn getSearchSelected(self: *Self) u64 { + return self.private().search_selected orelse 0; + } + + fn getHasSearchSelected(self: *Self) bool { + return self.private().search_selected != null; + } + + fn closureMatchLabel( + _: *Self, + has_selected: bool, + selected: u64, + has_total: bool, + total: u64, + ) callconv(.c) ?[*:0]const u8 { + if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0"); + var buf: [32]u8 = undefined; + const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ + if (has_selected) selected + 1 else 0, + total, + }) catch return null; + return glib.ext.dupeZ(u8, label); + } + + //--------------------------------------------------------------- + // Template callbacks + + fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { + const text = entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + + // NOTE: The callbacks below use anyopaque for the first parameter + // because they're shared with multiple widgets in the template. + + fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + + fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } + + fn searchEntryKeyPressed( + _: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + gtk_mods: gdk.ModifierType, + self: *Self, + ) callconv(.c) c_int { + if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) { + if (gtk_mods.shift_mask) { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } else { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + return 1; + } + + return 0; + } + + fn onDragEnd( + _: *gtk.GestureDrag, + offset_x: f64, + offset_y: f64, + self: *Self, + ) callconv(.c) void { + // On drag end, we want to move our halign/valign if we crossed + // the midpoint on either axis. This lets the search overlay be + // moved to different corners of the parent container. + + const priv = self.private(); + const widget = self.as(gtk.Widget); + const parent = widget.getParent() orelse return; + + const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth()); + const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight()); + const self_width: f64 = @floatFromInt(widget.getAllocatedWidth()); + const self_height: f64 = @floatFromInt(widget.getAllocatedHeight()); + + const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width; + const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height; + + const new_x = self_x + offset_x + (self_width / 2); + const new_y = self_y + offset_y + (self_height / 2); + + const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start; + const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start; + + var changed = false; + if (new_halign != priv.halign_target) { + priv.halign_target = new_halign; + self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec); + changed = true; + } + if (new_valign != priv.valign_target) { + priv.valign_target = new_valign; + self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec); + changed = true; + } + + if (changed) self.as(gtk.Widget).queueResize(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + _ = priv; + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + _ = priv; + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "search-overlay", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("search_entry", .{}); + + // Template Callbacks + class.bindTemplateCallback("stop_search", &stopSearch); + class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("match_label_closure", &closureMatchLabel); + class.bindTemplateCallback("next_match", &nextMatch); + class.bindTemplateCallback("previous_match", &previousMatch); + class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed); + class.bindTemplateCallback("on_drag_end", &onDragEnd); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.active.impl, + properties.@"search-total".impl, + properties.@"has-search-total".impl, + properties.@"search-selected".impl, + properties.@"has-search-selected".impl, + properties.@"halign-target".impl, + properties.@"valign-target".impl, + }); + + // Signals + signals.@"stop-search".impl.register(.{}); + signals.@"search-changed".impl.register(.{}); + signals.@"next-match".impl.register(.{}); + signals.@"previous-match".impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 9ba7ce0ab..548ae1a6a 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -25,6 +25,7 @@ const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; +const SearchOverlay = @import("search_overlay.zig").SearchOverlay; const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; @@ -549,6 +550,9 @@ pub const Surface = extern struct { /// The resize overlay resize_overlay: *ResizeOverlay, + /// The search overlay + search_overlay: *SearchOverlay, + /// The apprt Surface. rt_surface: ApprtSurface = undefined, @@ -1135,13 +1139,14 @@ pub const Surface = extern struct { if (entry.native == keycode) break :w3c entry.key; } else .unidentified; - // If the key should be remappable, then consult the pre-remapped - // XKB keyval/keysym to get the (possibly) remapped key. + // Consult the pre-remapped XKB keyval/keysym to get the (possibly) + // remapped key. If the W3C key or the remapped key + // is eligible for remapping, we use it. // // See the docs for `shouldBeRemappable` for why we even have to // do this in the first place. - if (w3c_key.shouldBeRemappable()) { - if (gtk_key.keyFromKeyval(keyval)) |remapped| + if (gtk_key.keyFromKeyval(keyval)) |remapped| { + if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable()) break :keycode remapped; } @@ -1951,6 +1956,29 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec); } + pub fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + var value = gobject.ext.Value.newFrom(active); + defer value.unset(); + gobject.Object.setProperty( + priv.search_overlay.as(gobject.Object), + SearchOverlay.properties.active.name, + &value, + ); + + if (active) { + priv.search_overlay.grabFocus(); + } + } + + pub fn setSearchTotal(self: *Self, total: ?usize) void { + self.private().search_overlay.setSearchTotal(total); + } + + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + self.private().search_overlay.setSearchSelected(selected); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -3170,6 +3198,35 @@ pub const Surface = extern struct { self.setTitleOverride(if (title.len == 0) null else title); } + fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.end_search) catch |err| { + log.warn("unable to perform end_search action err={}", .{err}); + }; + _ = self.private().gl_area.as(gtk.Widget).grabFocus(); + } + + fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| { + log.warn("unable to perform search action err={}", .{err}); + }; + } + + fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + + fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3184,6 +3241,7 @@ pub const Surface = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(ResizeOverlay); + gobject.ext.ensureType(SearchOverlay); gobject.ext.ensureType(ChildExited); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -3203,6 +3261,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("error_page", .{}); class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); + class.bindTemplateChildPrivate("search_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); @@ -3240,6 +3299,10 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_vadjustment", &propVAdjustment); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); + class.bindTemplateCallback("search_stop", &searchStop); + class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("search_next_match", &searchNextMatch); + class.bindTemplateCallback("search_previous_match", &searchPreviousMatch); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index 5620c9ca4..938d23ad8 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -34,6 +34,18 @@ label.url-overlay.right { border-radius: 6px 0px 0px 0px; } +/* + * GhosttySurface search overlay + */ +.search-overlay { + padding: 6px 8px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + /* * GhosttySurface resize overlay */ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp new file mode 100644 index 000000000..6523d4149 --- /dev/null +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -0,0 +1,94 @@ +using Gtk 4.0; +using Gdk 4.0; +using Adw 1; + +template $GhosttySearchOverlay: Adw.Bin { + visible: bind template.active; + halign-target: end; + valign-target: start; + halign: bind template.halign-target; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } + + Adw.Bin { + Box container { + styles [ + "background", + "search-overlay", + ] + + orientation: horizontal; + spacing: 6; + + SearchEntry search_entry { + placeholder-text: _("Find…"); + width-chars: 20; + hexpand: true; + stop-search => $stop_search(); + search-changed => $search_changed(); + next-match => $next_match(); + previous-match => $previous_match(); + + EventControllerKey { + // We need this so we capture before the SearchEntry. + propagation-phase: capture; + key-pressed => $search_entry_key_pressed(); + } + } + + Label { + styles [ + "dim-label", + ] + + label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as ; + width-chars: 6; + xalign: 1.0; + } + + Box button_box { + orientation: horizontal; + spacing: 1; + + styles [ + "linked", + ] + + Button prev_button { + icon-name: "go-up-symbolic"; + tooltip-text: _("Previous Match"); + clicked => $next_match(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + + Button next_button { + icon-name: "go-down-symbolic"; + tooltip-text: _("Next Match"); + clicked => $previous_match(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + + Button close_button { + icon-name: "window-close-symbolic"; + tooltip-text: _("Close"); + clicked => $stop_search(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + } +} diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 0596bf15d..4ebfeabfb 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -41,6 +41,34 @@ Overlay terminal_page { halign: start; has-arrow: false; } + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + EventControllerScroll { + scroll => $scroll(); + scroll-begin => $scroll_begin(); + scroll-end => $scroll_end(); + flags: both_axes; + } + + EventControllerMotion { + motion => $mouse_motion(); + leave => $mouse_leave(); + } + + GestureClick { + pressed => $mouse_down(); + released => $mouse_up(); + button: 0; + } }; [overlay] @@ -64,6 +92,10 @@ Overlay terminal_page { reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; transition-type: crossfade; transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; Box bell_overlay { styles [ @@ -115,12 +147,26 @@ Overlay terminal_page { label: bind template.mouse-hover-url; } + [overlay] + $GhosttySearchOverlay search_overlay { + stop-search => $search_stop(); + search-changed => $search_changed(); + next-match => $search_next_match(); + previous-match => $search_previous_match(); + } + [overlay] // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface Revealer { reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as ; transition-duration: 0; + // This is all necessary so that the Revealer itself doesn't override + // any input events from the other overlays. Namely, if you don't have + // these then the search overlay won't get mouse events. + can-focus: false; + can-target: false; + focusable: false; DrawingArea { styles [ @@ -129,35 +175,6 @@ Overlay terminal_page { } } - // Event controllers for interactivity - EventControllerFocus { - enter => $focus_enter(); - leave => $focus_leave(); - } - - EventControllerKey { - key-pressed => $key_pressed(); - key-released => $key_released(); - } - - EventControllerMotion { - motion => $mouse_motion(); - leave => $mouse_leave(); - } - - EventControllerScroll { - scroll => $scroll(); - scroll-begin => $scroll_begin(); - scroll-end => $scroll_end(); - flags: both_axes; - } - - GestureClick { - pressed => $mouse_down(); - released => $mouse_up(); - button: 0; - } - DropTarget drop_target { drop => $drop(); actions: copy; diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 092322689..600aa4883 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -170,11 +170,11 @@ pub const Resource = struct { /// Returns true if the dist path exists at build time. pub fn exists(self: *const Resource, b: *std.Build) bool { - if (std.fs.accessAbsolute(b.pathFromRoot(self.dist), .{})) { + if (b.build_root.handle.access(self.dist, .{})) { // If we have a ".git" directory then we're a git checkout // and we never want to use the dist path. This shouldn't happen // so show a warning to the user. - if (std.fs.accessAbsolute(b.pathFromRoot(".git"), .{})) { + if (b.build_root.handle.access(".git", .{})) { std.log.warn( "dist resource '{s}' should not be in a git checkout", .{self.dist}, diff --git a/src/config/Config.zig b/src/config/Config.zig index bac7d3443..20256e951 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6098,6 +6098,20 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .ctrl = true, .shift = true } }, + .start_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .end_search, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, @@ -7973,7 +7987,8 @@ pub const QuickTerminalSize = struct { tag: Tag, value: Value, - pub const Tag = enum(u8) { none, percentage, pixels }; + /// c_int because it needs to be extern compatible + pub const Tag = enum(c_int) { none, percentage, pixels }; pub const Value = extern union { percentage: f32, diff --git a/src/os/main.zig b/src/os/main.zig index 2d269e412..c105f6143 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -69,6 +69,7 @@ test { _ = i18n; _ = path; _ = uri; + _ = shell; if (comptime builtin.os.tag == .linux) { _ = kernel_info; diff --git a/src/os/shell.zig b/src/os/shell.zig index a6f23e843..9fce3e385 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -5,8 +5,6 @@ const Writer = std.Io.Writer; /// Writer that escapes characters that shells treat specially to reduce the /// risk of injection attacks or other such weirdness. Specifically excludes /// linefeeds so that they can be used to delineate lists of file paths. -/// -/// T should be a Zig type that follows the `std.Io.Writer` interface. pub const ShellEscapeWriter = struct { writer: Writer, child: *Writer, @@ -33,7 +31,7 @@ pub const ShellEscapeWriter = struct { var count: usize = 0; for (data[0 .. data.len - 1]) |chunk| try self.writeEscaped(chunk, &count); - for (0..splat) |_| try self.writeEscaped(data[data.len], &count); + for (0..splat) |_| try self.writeEscaped(data[data.len - 1], &count); return count; } @@ -67,7 +65,7 @@ pub const ShellEscapeWriter = struct { test "shell escape 1" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("abc"); try testing.expectEqualStrings("abc", writer.buffered()); } @@ -75,7 +73,7 @@ test "shell escape 1" { test "shell escape 2" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a c"); try testing.expectEqualStrings("a\\ c", writer.buffered()); } @@ -83,7 +81,7 @@ test "shell escape 2" { test "shell escape 3" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a?c"); try testing.expectEqualStrings("a\\?c", writer.buffered()); } @@ -91,7 +89,7 @@ test "shell escape 3" { test "shell escape 4" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a\\c"); try testing.expectEqualStrings("a\\\\c", writer.buffered()); } @@ -99,7 +97,7 @@ test "shell escape 4" { test "shell escape 5" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a|c"); try testing.expectEqualStrings("a\\|c", writer.buffered()); } @@ -107,7 +105,7 @@ test "shell escape 5" { test "shell escape 6" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a\"c"); try testing.expectEqualStrings("a\\\"c", writer.buffered()); } @@ -115,7 +113,7 @@ test "shell escape 6" { test "shell escape 7" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a(1)"); try testing.expectEqualStrings("a\\(1\\)", writer.buffered()); } diff --git a/src/quirks.zig b/src/quirks.zig index 5129923d2..ecef74600 100644 --- a/src/quirks.zig +++ b/src/quirks.zig @@ -7,6 +7,7 @@ //! [1]: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/Quirks.cpp const std = @import("std"); +const builtin = @import("builtin"); const font = @import("font/main.zig"); @@ -41,6 +42,16 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool { /// is negligible, but we have some asserts inside tight loops and hotpaths /// that cause significant overhead (as much as 15-20%) when they don't get /// optimized out. -pub inline fn inlineAssert(ok: bool) void { - if (!ok) unreachable; -} +pub const inlineAssert = switch (builtin.mode) { + // In debug builds we just use std.debug.assert because this + // fixes up stack traces. `inline` causes broken stack traces. This + // is probably a Zig compiler bug but until it is fixed we have to + // do this for development sanity. + .Debug => std.debug.assert, + + .ReleaseSmall, .ReleaseSafe, .ReleaseFast => (struct { + inline fn assert(ok: bool) void { + if (!ok) unreachable; + } + }).assert, +}; diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 1fd11091d..3f8543c68 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -83,8 +83,10 @@ from the `zsh` directory. The existing `ZDOTDIR` is retained so that after loading the Ghostty shell integration the normal Zsh loading sequence occurs. -```bash +```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi ``` + +Shell integration requires Zsh 5.1+. diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv index 3332b1c1f..62dcf273c 100644 --- a/src/shell-integration/zsh/.zshenv +++ b/src/shell-integration/zsh/.zshenv @@ -15,11 +15,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# This script is sourced automatically by zsh when ZDOTDIR is set to this +# directory. It therefore assumes it's running within our shell integration +# environment and should not be sourced manually (unlike ghostty-integration). +# # This file can get sourced with aliases enabled. To avoid alias expansion # we quote everything that can be quoted. Some aliases will still break us # though. -# Restore the original ZDOTDIR value. +# Restore the original ZDOTDIR value if GHOSTTY_ZSH_ZDOTDIR is set. +# Otherwise, unset the ZDOTDIR that was set during shell injection. if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then 'builtin' 'export' ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" 'builtin' 'unset' 'GHOSTTY_ZSH_ZDOTDIR' @@ -43,12 +48,6 @@ fi [[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file" } always { if [[ -o 'interactive' ]]; then - 'builtin' 'autoload' '--' 'is-at-least' - 'is-at-least' "5.1" || { - builtin echo "ZSH ${ZSH_VERSION} is too old for ghostty shell integration" > /dev/stderr - 'builtin' 'unset' '_ghostty_file' - return - } # ${(%):-%x} is the path to the current file. # On top of it we add :A:h to get the directory. 'builtin' 'typeset' _ghostty_file="${${(%):-%x}:A:h}"/ghostty-integration diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 27ef39bbc..7ff43efd9 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -1,3 +1,5 @@ +# vim:ft=zsh +# # Based on (started as) a copy of Kitty's zsh integration. Kitty is # distributed under GPLv3, so this file is also distributed under GPLv3. # The license header is reproduced below: @@ -41,6 +43,13 @@ _entrypoint() { [[ -o interactive ]] || builtin return 0 # non-interactive shell (( ! $+_ghostty_state )) || builtin return 0 # already initialized + # We require zsh 5.1+ (released Sept 2015) for features like functions_source, + # introspection arrays, and array pattern substitution. + if ! { builtin autoload -- is-at-least 2>/dev/null && is-at-least 5.1; }; then + builtin echo "Zsh ${ZSH_VERSION} is too old for ghostty shell integration (5.1+ required)" >&2 + builtin return 1 + fi + # 0: no OSC 133 [AC] marks have been written yet. # 1: the last written OSC 133 C has not been closed with D yet. # 2: none of the above. diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e7cb56da7..29f414e03 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2608,7 +2608,9 @@ pub fn adjustCapacity( errdefer self.destroyNode(new_node); const new_page: *Page = &new_node.data; assert(new_page.capacity.rows >= page.capacity.rows); + assert(new_page.capacity.cols >= page.capacity.cols); new_page.size.rows = page.size.rows; + new_page.size.cols = page.size.cols; try new_page.cloneFrom(page, 0, page.size.rows); // Fix up all our tracked pins to point to the new page. @@ -6257,6 +6259,39 @@ test "PageList adjustCapacity to increase hyperlinks" { } } +test "PageList adjustCapacity after col shrink" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 2, 0); + defer s.deinit(); + + // Shrink columns - this updates size.cols but not capacity.cols + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(5, s.cols); + + { + const page = &s.pages.first.?.data; + // capacity.cols is still 10, but size.cols should be 5 + try testing.expectEqual(5, page.size.cols); + try testing.expect(page.capacity.cols >= 10); + } + + // Now adjust capacity (e.g., to increase styles) + // This should preserve the current size.cols, not revert to capacity.cols + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .styles = std_capacity.styles * 2 }, + ); + + { + const page = &s.pages.first.?.data; + // After adjustCapacity, size.cols should still be 5, not 10 + try testing.expectEqual(5, page.size.cols); + try testing.expectEqual(5, s.cols); + } +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 52f696131..425325d4a 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -26,7 +26,7 @@ pub const Handler = struct { assert(self.state == .inactive); // Initialize our state to ignore in case of error - self.state = .{ .ignore = {} }; + self.state = .ignore; // Try to parse the hook. const hk_ = self.tryHook(alloc, dcs) catch |err| { @@ -70,7 +70,7 @@ pub const Handler = struct { ), }, }, - .command = .{ .tmux = .{ .enter = {} } }, + .command = .{ .tmux = .enter }, }; }, @@ -116,7 +116,7 @@ pub const Handler = struct { // On error we just discard our state and ignore the rest log.info("error putting byte into DCS handler err={}", .{err}); self.discard(); - self.state = .{ .ignore = {} }; + self.state = .ignore; return null; }; } @@ -158,7 +158,7 @@ pub const Handler = struct { // Note: we do NOT call deinit here on purpose because some commands // transfer memory ownership. If state needs cleanup, the switch // prong below should handle it. - defer self.state = .{ .inactive = {} }; + defer self.state = .inactive; return switch (self.state) { .inactive, @@ -167,7 +167,7 @@ pub const Handler = struct { .tmux => if (comptime build_options.tmux_control_mode) tmux: { self.state.deinit(); - break :tmux .{ .tmux = .{ .exit = {} } }; + break :tmux .{ .tmux = .exit }; } else unreachable, .xtgettcap => |*list| xtgettcap: { @@ -200,7 +200,7 @@ pub const Handler = struct { fn discard(self: *Handler) void { self.state.deinit(); - self.state = .{ .inactive = {} }; + self.state = .inactive; } }; @@ -213,7 +213,7 @@ pub const Command = union(enum) { /// Tmux control mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Notification + terminal.tmux.ControlNotification else void, @@ -255,21 +255,15 @@ pub const Command = union(enum) { decstbm, decslrm, }; - - /// Tmux control mode - pub const Tmux = union(enum) { - enter: void, - exit: void, - }; }; const State = union(enum) { /// We're not in a DCS state at the moment. - inactive: void, + inactive, /// We're hooked, but its an unknown DCS command or one that went /// invalid due to some bad input, so we're ignoring the rest. - ignore: void, + ignore, /// XTGETTCAP xtgettcap: std.Io.Writer.Allocating, @@ -282,7 +276,7 @@ const State = union(enum) { /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Client + terminal.tmux.ControlParser else void, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 83b4a7145..b6430ea34 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -385,6 +385,7 @@ pub const RenderState = struct { const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_sels = row_data.items(.selection); + const row_highlights = row_data.items(.highlights); const row_dirties = row_data.items(.dirty); // Track the last page that we know was dirty. This lets us @@ -468,6 +469,7 @@ pub const RenderState = struct { _ = arena.reset(.retain_capacity); row_cells[y].clearRetainingCapacity(); row_sels[y] = null; + row_highlights[y] = .empty; } row_dirties[y] = true; @@ -1314,3 +1316,62 @@ test "string" { const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n"; try testing.expectEqualStrings(expected, result); } + +test "dirty row resets highlights" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABC"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Reset dirty state + state.dirty = .false; + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + @memset(dirty, false); + } + + // Manually add a highlight to row 0 + { + const row_data = state.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_highlights = row_data.items(.highlights); + var arena = row_arenas[0].promote(alloc); + defer row_arenas[0] = arena.state; + try row_highlights[0].append(arena.allocator(), .{ + .tag = 1, + .range = .{ 0, 2 }, + }); + } + + // Verify we have a highlight + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights); + try testing.expectEqual(1, row_highlights[0].items.len); + } + + // Write to row 0 to make it dirty + try s.nextSlice("\x1b[H"); // Move to home + try s.nextSlice("X"); + try state.update(alloc, &t); + + // Verify the highlight was reset on the dirty row + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights); + try testing.expectEqual(0, row_highlights[0].items.len); + } +} diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 56d4c5fe2..82ef5036b 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -1,435 +1,12 @@ -//! This file contains the implementation for tmux control mode. See -//! tmux(1) for more information on control mode. Some basics are documented -//! here but this is not meant to be a comprehensive source of protocol -//! documentation. +//! Types and functions related to tmux protocols. -const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const oni = @import("oniguruma"); +const control = @import("tmux/control.zig"); +const layout = @import("tmux/layout.zig"); +pub const output = @import("tmux/output.zig"); +pub const ControlParser = control.Parser; +pub const ControlNotification = control.Notification; +pub const Layout = layout.Layout; -const log = std.log.scoped(.terminal_tmux); - -/// A tmux control mode client. It is expected that the caller establishes -/// the connection in some way (i.e. detects the opening DCS sequence). This -/// just works on a byte stream. -pub const Client = struct { - /// Current state of the client. - state: State = .idle, - - /// The buffer used to store in-progress notifications, output, etc. - buffer: std.Io.Writer.Allocating, - - /// The maximum size in bytes of the buffer. This is used to limit - /// memory usage. If the buffer exceeds this size, the client will - /// enter a broken state (the control mode session will be forcibly - /// exited and future data dropped). - max_bytes: usize = 1024 * 1024, - - const State = enum { - /// Outside of any active notifications. This should drop any output - /// unless it is '%' on the first byte of a line. The buffer will be - /// cleared when it sees '%', this is so that the previous notification - /// data is valid until we receive/process new data. - idle, - - /// We experienced unexpected input and are in a broken state - /// so we cannot continue processing. When this state is set, - /// the buffer has been deinited and must not be accessed. - broken, - - /// Inside an active notification (started with '%'). - notification, - - /// Inside a begin/end block. - block, - }; - - pub fn deinit(self: *Client) void { - // If we're in a broken state, we already deinited - // the buffer, so we don't need to do anything. - if (self.state == .broken) return; - - self.buffer.deinit(); - } - - // Handle a byte of input. - pub fn put(self: *Client, byte: u8) !?Notification { - // If we're in a broken state, just do nothing. - // - // We have to do this check here before we check the buffer, because if - // we're in a broken state then we'd have already deinited the buffer. - if (self.state == .broken) return null; - - if (self.buffer.written().len >= self.max_bytes) { - self.broken(); - return error.OutOfMemory; - } - - switch (self.state) { - // Drop because we're in a broken state. - .broken => return null, - - // Waiting for a notification so if the byte is not '%' then - // we're in a broken state. Control mode output should always - // be wrapped in '%begin/%end' orelse we expect a notification. - // Return an exit notification. - .idle => if (byte != '%') { - self.broken(); - return .{ .exit = {} }; - } else { - self.buffer.clearRetainingCapacity(); - self.state = .notification; - }, - - // If we're in a notification and its not a newline then - // we accumulate. If it is a newline then we have a - // complete notification we need to parse. - .notification => if (byte == '\n') { - // We have a complete notification, parse it. - return try self.parseNotification(); - }, - - // If we're in a block then we accumulate until we see a newline - // and then we check to see if that line ended the block. - .block => if (byte == '\n') { - const written = self.buffer.written(); - const idx = if (std.mem.lastIndexOfScalar( - u8, - written, - '\n', - )) |v| v + 1 else 0; - const line = written[idx..]; - - if (std.mem.startsWith(u8, line, "%end") or - std.mem.startsWith(u8, line, "%error")) - { - const err = std.mem.startsWith(u8, line, "%error"); - const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); - - // If it is an error then log it. - if (err) log.warn("tmux control mode error={s}", .{output}); - - // Important: do not clear buffer since the notification - // contains it. - self.state = .idle; - return if (err) .{ .block_err = output } else .{ .block_end = output }; - } - - // Didn't end the block, continue accumulating. - }, - } - - try self.buffer.writer.writeByte(byte); - - return null; - } - - fn parseNotification(self: *Client) !?Notification { - assert(self.state == .notification); - - const line = line: { - var line = self.buffer.written(); - if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; - break :line line; - }; - const cmd = cmd: { - const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; - break :cmd line[0..idx]; - }; - - // The notification MUST exist because we guard entering the notification - // state on seeing at least a '%'. - if (std.mem.eql(u8, cmd, "%begin")) { - // We don't use the rest of the tokens for now because tmux - // claims to guarantee that begin/end are always in order and - // never intermixed. In the future, we should probably validate - // this. - // TODO(tmuxcc): do this before merge? - - // Move to block state because we expect a corresponding end/error - // and want to accumulate the data. - self.state = .block; - self.buffer.clearRetainingCapacity(); - return null; - } else if (std.mem.eql(u8, cmd, "%output")) cmd: { - var re = try oni.Regex.init( - "^%output %([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const data = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .output = .{ .pane_id = id, .data = data } }; - } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { - var re = try oni.Regex.init( - "^%session-changed \\$([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .session_changed = .{ .id = id, .name = name } }; - } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { - if (!std.mem.eql(u8, line, "%sessions-changed")) { - log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); - break :cmd; - } - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .sessions_changed = {} }; - } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { - var re = try oni.Regex.init( - "^%window-add @([0-9]+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .window_add = .{ .id = id } }; - } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { - var re = try oni.Regex.init( - "^%window-renamed @([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .window_renamed = .{ .id = id, .name = name } }; - } else { - // Unknown notification, log it and return to idle state. - log.warn("unknown tmux control mode notification={s}", .{cmd}); - } - - // Unknown command. Clear the buffer and return to idle state. - self.buffer.clearRetainingCapacity(); - self.state = .idle; - - return null; - } - - // Mark the tmux state as broken. - fn broken(self: *Client) void { - self.state = .broken; - self.buffer.deinit(); - } -}; - -/// Possible notification types from tmux control mode. These are documented -/// in tmux(1). -pub const Notification = union(enum) { - enter: void, - exit: void, - - block_end: []const u8, - block_err: []const u8, - - output: struct { - pane_id: usize, - data: []const u8, // unescaped - }, - - session_changed: struct { - id: usize, - name: []const u8, - }, - - sessions_changed: void, - - window_add: struct { - id: usize, - }, - - window_renamed: struct { - id: usize, - name: []const u8, - }, -}; - -test "tmux begin/end empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("", n.block_end); -} - -test "tmux begin/error empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_err); - try testing.expectEqualStrings("", n.block_err); -} - -test "tmux begin/end data" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("hello\nworld", n.block_end); -} - -test "tmux output" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .output); - try testing.expectEqual(42, n.output.pane_id); - try testing.expectEqualStrings("foo bar baz", n.output.data); -} - -test "tmux session-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .session_changed); - try testing.expectEqual(42, n.session_changed.id); - try testing.expectEqualStrings("foo", n.session_changed.name); -} - -test "tmux sessions-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux sessions-changed carriage return" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux window-add" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_add); - try testing.expectEqual(14, n.window_add.id); -} - -test "tmux window-renamed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_renamed); - try testing.expectEqual(42, n.window_renamed.id); - try testing.expectEqualStrings("bar", n.window_renamed.name); +test { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig new file mode 100644 index 000000000..3624173dd --- /dev/null +++ b/src/terminal/tmux/control.zig @@ -0,0 +1,701 @@ +//! This file contains the implementation for tmux control mode. See +//! tmux(1) for more information on control mode. Some basics are documented +//! here but this is not meant to be a comprehensive source of protocol +//! documentation. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../../quirks.zig").inlineAssert; +const oni = @import("oniguruma"); + +const log = std.log.scoped(.terminal_tmux); + +/// A tmux control mode parser. This takes in output from tmux control +/// mode and parses it into a structured notifications. +/// +/// It is up to the caller to establish the connection to the tmux +/// control mode session in some way (e.g. via exec, a network socket, +/// whatever). This is fully agnostic to how the data is received and sent. +pub const Parser = struct { + /// Current state of the client. + state: State = .idle, + + /// The buffer used to store in-progress notifications, output, etc. + buffer: std.Io.Writer.Allocating, + + /// The maximum size in bytes of the buffer. This is used to limit + /// memory usage. If the buffer exceeds this size, the client will + /// enter a broken state (the control mode session will be forcibly + /// exited and future data dropped). + max_bytes: usize = 1024 * 1024, + + const State = enum { + /// Outside of any active notifications. This should drop any output + /// unless it is '%' on the first byte of a line. The buffer will be + /// cleared when it sees '%', this is so that the previous notification + /// data is valid until we receive/process new data. + idle, + + /// We experienced unexpected input and are in a broken state + /// so we cannot continue processing. When this state is set, + /// the buffer has been deinited and must not be accessed. + broken, + + /// Inside an active notification (started with '%'). + notification, + + /// Inside a begin/end block. + block, + }; + + pub fn deinit(self: *Parser) void { + // If we're in a broken state, we already deinited + // the buffer, so we don't need to do anything. + if (self.state == .broken) return; + + self.buffer.deinit(); + } + + // Handle a byte of input. + // + // If we reach our byte limit this will return OutOfMemory. It only + // does this on the first time we exceed the limit; subsequent calls + // will return null as we drop all input in a broken state. + pub fn put(self: *Parser, byte: u8) Allocator.Error!?Notification { + // If we're in a broken state, just do nothing. + // + // We have to do this check here before we check the buffer, because if + // we're in a broken state then we'd have already deinited the buffer. + if (self.state == .broken) return null; + + if (self.buffer.written().len >= self.max_bytes) { + self.broken(); + return error.OutOfMemory; + } + + switch (self.state) { + // Drop because we're in a broken state. + .broken => return null, + + // Waiting for a notification so if the byte is not '%' then + // we're in a broken state. Control mode output should always + // be wrapped in '%begin/%end' orelse we expect a notification. + // Return an exit notification. + .idle => if (byte != '%') { + self.broken(); + return .{ .exit = {} }; + } else { + self.buffer.clearRetainingCapacity(); + self.state = .notification; + }, + + // If we're in a notification and its not a newline then + // we accumulate. If it is a newline then we have a + // complete notification we need to parse. + .notification => if (byte == '\n') { + // We have a complete notification, parse it. + return self.parseNotification() catch { + // If parsing failed, then we do not mark the state + // as broken because we may be able to continue parsing + // other types of notifications. + // + // In the future we may want to emit a notification + // here about unknown or unsupported notifications. + return null; + }; + }, + + // If we're in a block then we accumulate until we see a newline + // and then we check to see if that line ended the block. + .block => if (byte == '\n') { + const written = self.buffer.written(); + const idx = if (std.mem.lastIndexOfScalar( + u8, + written, + '\n', + )) |v| v + 1 else 0; + const line = written[idx..]; + + if (std.mem.startsWith(u8, line, "%end") or + std.mem.startsWith(u8, line, "%error")) + { + const err = std.mem.startsWith(u8, line, "%error"); + const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); + + // If it is an error then log it. + if (err) log.warn("tmux control mode error={s}", .{output}); + + // Important: do not clear buffer since the notification + // contains it. + self.state = .idle; + return if (err) .{ .block_err = output } else .{ .block_end = output }; + } + + // Didn't end the block, continue accumulating. + }, + } + + self.buffer.writer.writeByte(byte) catch |err| switch (err) { + error.WriteFailed => return error.OutOfMemory, + }; + + return null; + } + + const ParseError = error{RegexError}; + + fn parseNotification(self: *Parser) ParseError!?Notification { + assert(self.state == .notification); + + const line = line: { + var line = self.buffer.written(); + if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; + break :line line; + }; + const cmd = cmd: { + const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; + break :cmd line[0..idx]; + }; + + // The notification MUST exist because we guard entering the notification + // state on seeing at least a '%'. + if (std.mem.eql(u8, cmd, "%begin")) { + // We don't use the rest of the tokens for now because tmux + // claims to guarantee that begin/end are always in order and + // never intermixed. In the future, we should probably validate + // this. + // TODO(tmuxcc): do this before merge? + + // Move to block state because we expect a corresponding end/error + // and want to accumulate the data. + self.state = .block; + self.buffer.clearRetainingCapacity(); + return null; + } else if (std.mem.eql(u8, cmd, "%output")) cmd: { + var re = oni.Regex.init( + "^%output %([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const data = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .output = .{ .pane_id = id, .data = data } }; + } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { + var re = oni.Regex.init( + "^%session-changed \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .session_changed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { + if (!std.mem.eql(u8, line, "%sessions-changed")) { + log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); + break :cmd; + } + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .sessions_changed = {} }; + } else if (std.mem.eql(u8, cmd, "%layout-change")) cmd: { + var re = oni.Regex.init( + "^%layout-change @([0-9]+) (.+) (.+) (.*)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const layout = line[@intCast(starts[2])..@intCast(ends[2])]; + const visible_layout = line[@intCast(starts[3])..@intCast(ends[3])]; + const raw_flags = line[@intCast(starts[4])..@intCast(ends[4])]; + + // Important: do not clear buffer here since layout strings point to it + self.state = .idle; + return .{ .layout_change = .{ + .window_id = id, + .layout = layout, + .visible_layout = visible_layout, + .raw_flags = raw_flags, + } }; + } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { + var re = oni.Regex.init( + "^%window-add @([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_add = .{ .id = id } }; + } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { + var re = oni.Regex.init( + "^%window-renamed @([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .window_renamed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { + var re = oni.Regex.init( + "^%window-pane-changed @([0-9]+) %([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const window_id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const pane_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; + } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { + var re = oni.Regex.init( + "^%client-detached (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + + // Important: do not clear buffer here since client points to it + self.state = .idle; + return .{ .client_detached = .{ .client = client } }; + } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { + var re = oni.Regex.init( + "^%client-session-changed (.+) \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + const session_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[3])..@intCast(ends[3])]; + + // Important: do not clear buffer here since client/name point to it + self.state = .idle; + return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; + } else { + // Unknown notification, log it and return to idle state. + log.warn("unknown tmux control mode notification={s}", .{cmd}); + } + + // Unknown command. Clear the buffer and return to idle state. + self.buffer.clearRetainingCapacity(); + self.state = .idle; + + return null; + } + + // Mark the tmux state as broken. + fn broken(self: *Parser) void { + self.state = .broken; + self.buffer.deinit(); + } +}; + +/// Possible notification types from tmux control mode. These are documented +/// in tmux(1). A lot of the simple documentation was copied from that man +/// page here. +pub const Notification = union(enum) { + /// Entering tmux control mode. This isn't an actual event sent by + /// tmux but is one sent by us to indicate that we have detected that + /// tmux control mode is starting. + enter, + + /// Exit. + /// + /// NOTE: The tmux protocol contains a "reason" string (human friendly) + /// associated with this. We currently drop it because we don't need it + /// but this may be something we want to add later. If we do add it, + /// we have to consider buffer limits and how we handle those (dropping + /// vs truncating, etc.). + exit, + + /// Dispatched at the end of a begin/end block with the raw data. + /// The control mode parser can't parse the data because it is unaware + /// of the command that was sent to trigger this output. + block_end: []const u8, + block_err: []const u8, + + /// Raw output from a pane. + output: struct { + pane_id: usize, + data: []const u8, // unescaped + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + session_changed: struct { + id: usize, + name: []const u8, + }, + + /// A session was created or destroyed. + sessions_changed, + + /// The layout of the window with ID window-id changed. + layout_change: struct { + window_id: usize, + layout: []const u8, + visible_layout: []const u8, + raw_flags: []const u8, + }, + + /// The window with ID window-id was linked to the current session. + window_add: struct { + id: usize, + }, + + /// The window with ID window-id was renamed to name. + window_renamed: struct { + id: usize, + name: []const u8, + }, + + /// The active pane in the window with ID window-id changed to the pane + /// with ID pane-id. + window_pane_changed: struct { + window_id: usize, + pane_id: usize, + }, + + /// The client has detached. + client_detached: struct { + client: []const u8, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + client_session_changed: struct { + client: []const u8, + session_id: usize, + name: []const u8, + }, +}; + +test "tmux begin/end empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("", n.block_end); +} + +test "tmux begin/error empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_err); + try testing.expectEqualStrings("", n.block_err); +} + +test "tmux begin/end data" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("hello\nworld", n.block_end); +} + +test "tmux output" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(42, n.output.pane_id); + try testing.expectEqualStrings("foo bar baz", n.output.data); +} + +test "tmux session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .session_changed); + try testing.expectEqual(42, n.session_changed.id); + try testing.expectEqualStrings("foo", n.session_changed.name); +} + +test "tmux sessions-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux sessions-changed carriage return" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux layout-change" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .layout_change); + try testing.expectEqual(2, n.layout_change.window_id); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.layout); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.visible_layout); + try testing.expectEqualStrings("*-", n.layout_change.raw_flags); +} + +test "tmux window-add" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_add); + try testing.expectEqual(14, n.window_add.id); +} + +test "tmux window-renamed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_renamed); + try testing.expectEqual(42, n.window_renamed.id); + try testing.expectEqualStrings("bar", n.window_renamed.name); +} + +test "tmux window-pane-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_pane_changed); + try testing.expectEqual(42, n.window_pane_changed.window_id); + try testing.expectEqual(2, n.window_pane_changed.pane_id); +} + +test "tmux client-detached" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_detached); + try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); +} + +test "tmux client-session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_session_changed); + try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); + try testing.expectEqual(2, n.client_session_changed.session_id); + try testing.expectEqualStrings("mysession", n.client_session_changed.name); +} diff --git a/src/terminal/tmux/layout.zig b/src/terminal/tmux/layout.zig new file mode 100644 index 000000000..df1a53917 --- /dev/null +++ b/src/terminal/tmux/layout.zig @@ -0,0 +1,638 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +/// A tmux layout. +/// +/// This is a tree structure so by definition it pretty much needs to be +/// allocated. We leave allocation up to the user of this struct, but +/// a general recommendation is to use an arena allocator for simplicity +/// in freeing the entire layout at once. +pub const Layout = struct { + /// Width, height of the node + width: usize, + height: usize, + + /// X and Y offset from the top-left corner of the window. + x: usize, + y: usize, + + /// The content of this node, either a pane (leaf) or more nodes + /// (split) horizontally or vertically. + content: Content, + + pub const Content = union(enum) { + pane: usize, + horizontal: []const Layout, + vertical: []const Layout, + }; + + pub const ParseError = Allocator.Error || error{SyntaxError}; + + /// Parse a layout string that includes a 4-character checksum prefix. + /// + /// The expected format is: `XXXX,layout_string` where XXXX is the + /// 4-character hexadecimal checksum and the layout string follows + /// after the comma. For example: `f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}`. + /// + /// Returns `ChecksumMismatch` if the checksum doesn't match the layout. + /// Returns `SyntaxError` if the format is invalid. + pub fn parseWithChecksum( + alloc: Allocator, + str: []const u8, + ) (ParseError || error{ChecksumMismatch})!Layout { + // If the string is less than 5 characters, it can't possibly + // be correct. 4-char checksum + comma. In practice it should + // be even longer, but that'll fail parse later. + if (str.len < 5) return error.SyntaxError; + if (str[4] != ',') return error.SyntaxError; + + // The layout string should start with a 4-character checksum. + const checksum: Checksum = .calculate(str[5..]); + if (!std.mem.startsWith( + u8, + str, + &checksum.asString(), + )) return error.ChecksumMismatch; + + // Checksum matches, parse the rest. + return try parse(alloc, str[5..]); + } + + /// Parse a layout string into a Layout structure. The given allocator + /// will be used for all allocations within the layout. Note that + /// individual nodes can't be freed so this allocator must be some + /// kind of arena allocator. + /// + /// The layout string must be fully provided as a single string. + /// Layouts are generally small so this should not be a problem. + /// + /// Tmux layout strings have the following format: + /// + /// - WxH,X,Y,ID Leaf pane: width×height, x-offset, y-offset, pane ID + /// - WxH,X,Y{...} Horizontal split (left-right), children comma-separated + /// - WxH,X,Y[...] Vertical split (top-bottom), children comma-separated + pub fn parse(alloc: Allocator, str: []const u8) ParseError!Layout { + var offset: usize = 0; + const root = try parseNext( + alloc, + str, + &offset, + ); + if (offset != str.len) return error.SyntaxError; + return root; + } + + fn parseNext( + alloc: Allocator, + str: []const u8, + offset: *usize, + ) ParseError!Layout { + // Find the first `x` to grab the width. + const width: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + 'x', + )) |idx| width: { + defer offset.* += idx + 1; // Consume `x` + break :width std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find the height, up to a comma. + const height: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + ',', + )) |idx| height: { + defer offset.* += idx + 1; // Consume `,` + break :height std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find X + const x: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + ',', + )) |idx| x: { + defer offset.* += idx + 1; // Consume `,` + break :x std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find Y, which can end in any of `,{,[` + const y: usize = if (std.mem.indexOfAny( + u8, + str[offset.*..], + ",{[", + )) |idx| y: { + defer offset.* += idx; // Don't consume the delimiter! + break :y std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Determine our child node. + const content: Layout.Content = switch (str[offset.*]) { + ',' => content: { + // Consume the delimiter + offset.* += 1; + + // Leaf pane. Read up to `,}]` because we may be in + // a set of nodes. If none exist, end of string is fine. + const idx = std.mem.indexOfAny( + u8, + str[offset.*..], + ",}]", + ) orelse str.len - offset.*; + + defer offset.* += idx; // Consume the pane ID, not the delimiter + const pane_id = std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + + break :content .{ .pane = pane_id }; + }, + + '{', '[' => |opening| content: { + var nodes: std.ArrayList(Layout) = .empty; + defer nodes.deinit(alloc); + + // Move beyond our opening + offset.* += 1; + + while (true) { + try nodes.append(alloc, try parseNext( + alloc, + str, + offset, + )); + + // We should not reach the end of string here because + // we expect a closing bracket. + if (offset.* >= str.len) return error.SyntaxError; + + // If it is a comma, we expect another node. + if (str[offset.*] == ',') { + offset.* += 1; // Consume + continue; + } + + // We expect a closing bracket now. + switch (opening) { + '{' => if (str[offset.*] != '}') return error.SyntaxError, + '[' => if (str[offset.*] != ']') return error.SyntaxError, + else => return error.SyntaxError, + } + + // Successfully parsed all children. + offset.* += 1; // Consume closing bracket + break :content switch (opening) { + '{' => .{ .horizontal = try nodes.toOwnedSlice(alloc) }, + '[' => .{ .vertical = try nodes.toOwnedSlice(alloc) }, + else => unreachable, + }; + } + }, + + // indexOfAny above guarantees we have only the above + else => unreachable, + }; + + return .{ + .width = width, + .height = height, + .x = x, + .y = y, + .content = content, + }; + } +}; + +pub const Checksum = enum(u16) { + _, + + /// Calculate the checksum of a tmux layout string. + /// The algorithm rotates the checksum right by 1 bit (with wraparound) + /// and adds the ASCII value of each character. + pub fn calculate(str: []const u8) Checksum { + var result: u16 = 0; + for (str) |c| { + // Rotate right by 1: (result >> 1) + ((result & 1) << 15) + result = (result >> 1) | ((result & 1) << 15); + result +%= c; + } + + return @enumFromInt(result); + } + + /// Convert the checksum to a 4-character hexadecimal string. This + /// is always zero-padded to match the tmux implementation + /// (in layout-custom.c). + pub fn asString(self: Checksum) [4]u8 { + const value = @intFromEnum(self); + const charset = "0123456789abcdef"; + return .{ + charset[(value >> 12) & 0xf], + charset[(value >> 8) & 0xf], + charset[(value >> 4) & 0xf], + charset[value & 0xf], + }; + } +}; + +test "simple single pane" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0,42"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + try testing.expectEqual(42, layout.content.pane); +} + +test "single pane with offset" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "40x12,10,5,7"); + try testing.expectEqual(40, layout.width); + try testing.expectEqual(12, layout.height); + try testing.expectEqual(10, layout.x); + try testing.expectEqual(5, layout.y); + try testing.expectEqual(7, layout.content.pane); +} + +test "single pane large values" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "1920x1080,100,200,999"); + try testing.expectEqual(1920, layout.width); + try testing.expectEqual(1080, layout.height); + try testing.expectEqual(100, layout.x); + try testing.expectEqual(200, layout.y); + try testing.expectEqual(999, layout.content.pane); +} + +test "horizontal split two panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + + const children = layout.content.horizontal; + try testing.expectEqual(2, children.len); + + try testing.expectEqual(40, children[0].width); + try testing.expectEqual(24, children[0].height); + try testing.expectEqual(0, children[0].x); + try testing.expectEqual(0, children[0].y); + try testing.expectEqual(1, children[0].content.pane); + + try testing.expectEqual(40, children[1].width); + try testing.expectEqual(24, children[1].height); + try testing.expectEqual(40, children[1].x); + try testing.expectEqual(0, children[1].y); + try testing.expectEqual(2, children[1].content.pane); +} + +test "vertical split two panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12,2]"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + + const children = layout.content.vertical; + try testing.expectEqual(2, children.len); + + try testing.expectEqual(80, children[0].width); + try testing.expectEqual(12, children[0].height); + try testing.expectEqual(0, children[0].x); + try testing.expectEqual(0, children[0].y); + try testing.expectEqual(1, children[0].content.pane); + + try testing.expectEqual(80, children[1].width); + try testing.expectEqual(12, children[1].height); + try testing.expectEqual(0, children[1].x); + try testing.expectEqual(12, children[1].y); + try testing.expectEqual(2, children[1].content.pane); +} + +test "horizontal split three panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}"); + try testing.expectEqual(120, layout.width); + try testing.expectEqual(24, layout.height); + + const children = layout.content.horizontal; + try testing.expectEqual(3, children.len); + try testing.expectEqual(1, children[0].content.pane); + try testing.expectEqual(2, children[1].content.pane); + try testing.expectEqual(3, children[2].content.pane); +} + +test "nested horizontal in vertical" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Vertical split with top pane and bottom horizontal split + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + + const vert_children = layout.content.vertical; + try testing.expectEqual(2, vert_children.len); + + // First child is a simple pane + try testing.expectEqual(1, vert_children[0].content.pane); + + // Second child is a horizontal split + const horiz_children = vert_children[1].content.horizontal; + try testing.expectEqual(2, horiz_children.len); + try testing.expectEqual(2, horiz_children[0].content.pane); + try testing.expectEqual(3, horiz_children[1].content.pane); +} + +test "nested vertical in horizontal" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Horizontal split with left pane and right vertical split + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + + const horiz_children = layout.content.horizontal; + try testing.expectEqual(2, horiz_children.len); + + // First child is a simple pane + try testing.expectEqual(1, horiz_children[0].content.pane); + + // Second child is a vertical split + const vert_children = horiz_children[1].content.vertical; + try testing.expectEqual(2, vert_children.len); + try testing.expectEqual(2, vert_children[0].content.pane); + try testing.expectEqual(3, vert_children[1].content.pane); +} + +test "deeply nested layout" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Three levels deep + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}"); + + const horiz = layout.content.horizontal; + try testing.expectEqual(2, horiz.len); + + const vert = horiz[0].content.vertical; + try testing.expectEqual(2, vert.len); + try testing.expectEqual(1, vert[0].content.pane); + try testing.expectEqual(2, vert[1].content.pane); + + try testing.expectEqual(3, horiz[1].content.pane); +} + +test "syntax error empty string" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "")); +} + +test "syntax error missing width" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "x24,0,0,1")); +} + +test "syntax error missing height" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x,0,0,1")); +} + +test "syntax error missing x" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,,0,1")); +} + +test "syntax error missing y" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,,1")); +} + +test "syntax error missing pane id" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,")); +} + +test "syntax error non-numeric width" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "abcx24,0,0,1")); +} + +test "syntax error non-numeric pane id" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,abc")); +} + +test "syntax error unclosed horizontal bracket" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1")); +} + +test "syntax error unclosed vertical bracket" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1")); +} + +test "syntax error mismatched brackets" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1]")); + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1}")); +} + +test "syntax error trailing data" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,1extra")); +} + +test "syntax error no x separator" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "8024,0,0,1")); +} + +test "syntax error no content delimiter" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0")); +} + +// parseWithChecksum tests + +test "parseWithChecksum valid" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parseWithChecksum(arena.allocator(), "f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); +} + +test "parseWithChecksum mismatch" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.ChecksumMismatch, Layout.parseWithChecksum(arena.allocator(), "0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}")); +} + +test "parseWithChecksum too short" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62")); + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "")); +} + +test "parseWithChecksum missing comma" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62x159x48,0,0")); +} + +// Checksum tests + +test "checksum empty string" { + const checksum = Checksum.calculate(""); + try testing.expectEqual(@as(u16, 0), @intFromEnum(checksum)); + try testing.expectEqualStrings("0000", &checksum.asString()); +} + +test "checksum single character" { + // 'A' = 65, first iteration: csum = 0 >> 1 | 0 = 0, then 0 + 65 = 65 + const checksum = Checksum.calculate("A"); + try testing.expectEqual(@as(u16, 65), @intFromEnum(checksum)); + try testing.expectEqualStrings("0041", &checksum.asString()); +} + +test "checksum two characters" { + // 'A' (65): csum = 0, rotate = 0, add 65 => 65 + // 'B' (66): csum = 65, rotate => (65 >> 1) | ((65 & 1) << 15) = 32 | 32768 = 32800 + // add 66 => 32800 + 66 = 32866 + const checksum = Checksum.calculate("AB"); + try testing.expectEqual(@as(u16, 32866), @intFromEnum(checksum)); + try testing.expectEqualStrings("8062", &checksum.asString()); +} + +test "checksum simple layout" { + const checksum = Checksum.calculate("80x24,0,0,42"); + try testing.expectEqualStrings("d962", &checksum.asString()); +} + +test "checksum horizontal split layout" { + const checksum = Checksum.calculate("80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqualStrings("f8f9", &checksum.asString()); +} + +test "checksum asString zero padding" { + // Value 0x000f should produce "000f" + const checksum: Checksum = @enumFromInt(0x000f); + try testing.expectEqualStrings("000f", &checksum.asString()); +} + +test "checksum asString all digits" { + // Value 0x1234 should produce "1234" + const checksum: Checksum = @enumFromInt(0x1234); + try testing.expectEqualStrings("1234", &checksum.asString()); +} + +test "checksum asString with letters" { + // Value 0xabcd should produce "abcd" + const checksum: Checksum = @enumFromInt(0xabcd); + try testing.expectEqualStrings("abcd", &checksum.asString()); +} + +test "checksum asString max value" { + // Value 0xffff should produce "ffff" + const checksum: Checksum = @enumFromInt(0xffff); + try testing.expectEqualStrings("ffff", &checksum.asString()); +} + +test "checksum wraparound" { + const checksum = Checksum.calculate("\xff\xff\xff\xff\xff\xff\xff\xff"); + try testing.expectEqualStrings("03fc", &checksum.asString()); +} + +test "checksum deterministic" { + // Same input should always produce same output + const str = "159x48,0,0{79x48,0,0,79x48,80,0}"; + const checksum1 = Checksum.calculate(str); + const checksum2 = Checksum.calculate(str); + try testing.expectEqual(checksum1, checksum2); +} + +test "checksum different inputs different outputs" { + const checksum1 = Checksum.calculate("80x24,0,0,1"); + const checksum2 = Checksum.calculate("80x24,0,0,2"); + try testing.expect(@intFromEnum(checksum1) != @intFromEnum(checksum2)); +} + +test "checksum known tmux layout bb62" { + // From tmux documentation: "bb62,159x48,0,0{79x48,0,0,79x48,80,0}" + // The checksum "bb62" corresponds to the layout "159x48,0,0{79x48,0,0,79x48,80,0}" + const checksum = Checksum.calculate("159x48,0,0{79x48,0,0,79x48,80,0}"); + try testing.expectEqualStrings("bb62", &checksum.asString()); +} diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig new file mode 100644 index 000000000..dcfa89ac3 --- /dev/null +++ b/src/terminal/tmux/output.zig @@ -0,0 +1,205 @@ +const std = @import("std"); +const testing = std.testing; + +pub const ParseError = error{ + MissingEntry, + ExtraEntry, + FormatError, +}; + +/// Parse the output from a command with the given format struct +/// (returned usually by FormatStruct). The format struct is expected +/// to be in the order of the variables used in the format string and +/// the variables are expected to be plain variables (no conditionals, +/// extra formatting, etc.). Each variable is expected to be separated +/// by a single `delimiter` character. +pub fn parseFormatStruct( + comptime T: type, + str: []const u8, + delimiter: u8, +) ParseError!T { + // Parse all our fields + const fields = @typeInfo(T).@"struct".fields; + var it = std.mem.splitScalar(u8, str, delimiter); + var result: T = undefined; + inline for (fields) |field| { + const part = it.next() orelse return error.MissingEntry; + @field(result, field.name) = Variable.parse( + @field(Variable, field.name), + part, + ) catch return error.FormatError; + } + + // We should have consumed all parts now. + if (it.next() != null) return error.ExtraEntry; + + return result; +} + +/// Returns a struct type that contains fields for each of the given +/// format variables. This can be used with `parseFormatStruct` to +/// parse an output string into a format struct. +pub fn FormatStruct(comptime vars: []const Variable) type { + var fields: [vars.len]std.builtin.Type.StructField = undefined; + for (vars, &fields) |variable, *field| { + field.* = .{ + .name = @tagName(variable), + .type = variable.Type(), + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(variable.Type()), + }; + } + + return @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +} + +/// Possible variables in a tmux format string that we support. +/// +/// Tmux supports a large number of variables, but we only implement +/// a subset of them here that are relevant to the use case of implementing +/// control mode for terminal emulators. +pub const Variable = enum { + session_id, + window_id, + window_width, + window_height, + window_layout, + + /// Parse the given string value into the appropriate resulting + /// type for this variable. + pub fn parse(comptime self: Variable, value: []const u8) !Type(self) { + return switch (self) { + .session_id => if (value.len >= 2 and value[0] == '$') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .window_id => if (value.len >= 2 and value[0] == '@') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .window_width => try std.fmt.parseInt(usize, value, 10), + .window_height => try std.fmt.parseInt(usize, value, 10), + .window_layout => value, + }; + } + + /// The type of the parsed value for this variable type. + pub fn Type(comptime self: Variable) type { + return switch (self) { + .session_id => usize, + .window_id => usize, + .window_width => usize, + .window_height => usize, + .window_layout => []const u8, + }; + } +}; + +test "parse session id" { + try testing.expectEqual(42, try Variable.parse(.session_id, "$42")); + try testing.expectEqual(0, try Variable.parse(.session_id, "$0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "$")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.session_id, "$abc")); +} + +test "parse window id" { + try testing.expectEqual(42, try Variable.parse(.window_id, "@42")); + try testing.expectEqual(0, try Variable.parse(.window_id, "@0")); + try testing.expectEqual(12345, try Variable.parse(.window_id, "@12345")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "$0")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "@")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_id, "@abc")); +} + +test "parse window width" { + try testing.expectEqual(80, try Variable.parse(.window_width, "80")); + try testing.expectEqual(0, try Variable.parse(.window_width, "0")); + try testing.expectEqual(12345, try Variable.parse(.window_width, "12345")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "abc")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "80px")); + try testing.expectError(error.Overflow, Variable.parse(.window_width, "-1")); +} + +test "parse window height" { + try testing.expectEqual(24, try Variable.parse(.window_height, "24")); + try testing.expectEqual(0, try Variable.parse(.window_height, "0")); + try testing.expectEqual(12345, try Variable.parse(.window_height, "12345")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "abc")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "24px")); + try testing.expectError(error.Overflow, Variable.parse(.window_height, "-1")); +} + +test "parse window layout" { + try testing.expectEqualStrings("abc123", try Variable.parse(.window_layout, "abc123")); + try testing.expectEqualStrings("", try Variable.parse(.window_layout, "")); + try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)")); +} + +test "parseFormatStruct single field" { + const T = FormatStruct(&.{.session_id}); + const result = try parseFormatStruct(T, "$42", ' '); + try testing.expectEqual(42, result.session_id); +} + +test "parseFormatStruct multiple fields" { + const T = FormatStruct(&.{ .session_id, .window_id, .window_width, .window_height }); + const result = try parseFormatStruct(T, "$1 @2 80 24", ' '); + try testing.expectEqual(1, result.session_id); + try testing.expectEqual(2, result.window_id); + try testing.expectEqual(80, result.window_width); + try testing.expectEqual(24, result.window_height); +} + +test "parseFormatStruct with string field" { + const T = FormatStruct(&.{ .window_id, .window_layout }); + const result = try parseFormatStruct(T, "@5,abc123", ','); + try testing.expectEqual(5, result.window_id); + try testing.expectEqualStrings("abc123", result.window_layout); +} + +test "parseFormatStruct different delimiter" { + const T = FormatStruct(&.{ .window_width, .window_height }); + const result = try parseFormatStruct(T, "120\t40", '\t'); + try testing.expectEqual(120, result.window_width); + try testing.expectEqual(40, result.window_height); +} + +test "parseFormatStruct missing entry" { + const T = FormatStruct(&.{ .session_id, .window_id }); + try testing.expectError(error.MissingEntry, parseFormatStruct(T, "$1", ' ')); +} + +test "parseFormatStruct extra entry" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.ExtraEntry, parseFormatStruct(T, "$1 @2", ' ')); +} + +test "parseFormatStruct format error" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.FormatError, parseFormatStruct(T, "42", ' ')); + try testing.expectError(error.FormatError, parseFormatStruct(T, "@42", ' ')); + try testing.expectError(error.FormatError, parseFormatStruct(T, "$abc", ' ')); +} + +test "parseFormatStruct empty string" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.FormatError, parseFormatStruct(T, "", ' ')); +} + +test "parseFormatStruct with empty layout field" { + const T = FormatStruct(&.{ .session_id, .window_layout }); + const result = try parseFormatStruct(T, "$1,", ','); + try testing.expectEqual(1, result.session_id); + try testing.expectEqualStrings("", result.window_layout); +} diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 8b2648dbd..c2a637b80 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -659,12 +659,12 @@ fn setupZsh( resource_dir: []const u8, env: *EnvMap, ) !void { - // Preserve the old zdotdir value so we can recover it. + // Preserve an existing ZDOTDIR value. We're about to overwrite it. if (env.get("ZDOTDIR")) |old| { try env.put("GHOSTTY_ZSH_ZDOTDIR", old); } - // Set our new ZDOTDIR + // Set our new ZDOTDIR to point to our shell resource directory. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const integ_dir = try std.fmt.bufPrint( &path_buf,