diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c1de0eb..f67c3533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Validate cmux scheme test configuration run: ./tests/test_ci_scheme_testaction_debug.sh + - name: Validate GhosttyKit checksum verification + run: ./tests/test_ci_ghosttykit_checksum_verification.sh + web-typecheck: runs-on: ubuntu-latest defaults: @@ -70,31 +73,8 @@ jobs: xcrun --sdk macosx --show-sdk-path - name: Download pre-built GhosttyKit.xcframework - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) - TAG="xcframework-$GHOSTTY_SHA" - URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" - echo "Downloading xcframework for ghostty $GHOSTTY_SHA" - MAX_RETRIES=30 - RETRY_DELAY=20 - for i in $(seq 1 $MAX_RETRIES); do - if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then - echo "Download succeeded on attempt $i" - break - fi - if [ "$i" -eq "$MAX_RETRIES" ]; then - echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 - exit 1 - fi - echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - done - tar xzf GhosttyKit.xcframework.tar.gz - rm GhosttyKit.xcframework.tar.gz - test -d GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - name: Clean DerivedData run: | @@ -203,31 +183,8 @@ jobs: xcodebuild -version - name: Download pre-built GhosttyKit.xcframework - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) - TAG="xcframework-$GHOSTTY_SHA" - URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" - echo "Downloading xcframework for ghostty $GHOSTTY_SHA" - MAX_RETRIES=30 - RETRY_DELAY=20 - for i in $(seq 1 $MAX_RETRIES); do - if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then - echo "Download succeeded on attempt $i" - break - fi - if [ "$i" -eq "$MAX_RETRIES" ]; then - echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 - exit 1 - fi - echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - done - tar xzf GhosttyKit.xcframework.tar.gz - rm GhosttyKit.xcframework.tar.gz - test -d GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - name: Clean DerivedData run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3b9a0866..18caadc3 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -116,31 +116,8 @@ jobs: npm install --global "create-dmg@${CREATE_DMG_VERSION}" - name: Download pre-built GhosttyKit.xcframework - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) - TAG="xcframework-$GHOSTTY_SHA" - URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" - echo "Downloading xcframework for ghostty $GHOSTTY_SHA" - MAX_RETRIES=30 - RETRY_DELAY=20 - for i in $(seq 1 $MAX_RETRIES); do - if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then - echo "Download succeeded on attempt $i" - break - fi - if [ "$i" -eq "$MAX_RETRIES" ]; then - echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 - exit 1 - fi - echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - done - tar xzf GhosttyKit.xcframework.tar.gz - rm GhosttyKit.xcframework.tar.gz - test -d GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - name: Cache Swift packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 200f003a..ec935c63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,31 +103,8 @@ jobs: - name: Download pre-built GhosttyKit.xcframework if: steps.guard_release_assets.outputs.skip_all != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) - TAG="xcframework-$GHOSTTY_SHA" - URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" - echo "Downloading xcframework for ghostty $GHOSTTY_SHA" - MAX_RETRIES=30 - RETRY_DELAY=20 - for i in $(seq 1 $MAX_RETRIES); do - if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then - echo "Download succeeded on attempt $i" - break - fi - if [ "$i" -eq "$MAX_RETRIES" ]; then - echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 - exit 1 - fi - echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - done - tar xzf GhosttyKit.xcframework.tar.gz - rm GhosttyKit.xcframework.tar.gz - test -d GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - name: Cache Swift packages if: steps.guard_release_assets.outputs.skip_all != 'true' diff --git a/AppIcon.icon/Assets/cmux-icon-chevron 2.png b/AppIcon.icon/Assets/cmux-icon-chevron 2.png new file mode 100644 index 00000000..9e5f23f1 Binary files /dev/null and b/AppIcon.icon/Assets/cmux-icon-chevron 2.png differ diff --git a/AppIcon.icon/icon.json b/AppIcon.icon/icon.json new file mode 100644 index 00000000..e4ddba51 --- /dev/null +++ b/AppIcon.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill" : "automatic", + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "cmux-icon-chevron 2.png", + "name" : "cmux-icon-chevron 2", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 37.357790031201375, + -0.5 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/128.png b/Assets.xcassets/AppIcon-Debug.appiconset/128.png index 38a667a1..f3915340 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/128.png and b/Assets.xcassets/AppIcon-Debug.appiconset/128.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png index d58bd7ed..7e65f28a 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/16.png b/Assets.xcassets/AppIcon-Debug.appiconset/16.png index cff0d96c..2db4b3ad 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/16.png and b/Assets.xcassets/AppIcon-Debug.appiconset/16.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png index 0514b3ce..03df358a 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/256.png b/Assets.xcassets/AppIcon-Debug.appiconset/256.png index d58bd7ed..7e65f28a 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/256.png and b/Assets.xcassets/AppIcon-Debug.appiconset/256.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png index 8b5bb49e..aab61e88 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/32.png b/Assets.xcassets/AppIcon-Debug.appiconset/32.png index 0514b3ce..03df358a 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/32.png and b/Assets.xcassets/AppIcon-Debug.appiconset/32.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png index dfeae3ae..8e2f7fa6 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/512.png b/Assets.xcassets/AppIcon-Debug.appiconset/512.png index 8b5bb49e..aab61e88 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/512.png and b/Assets.xcassets/AppIcon-Debug.appiconset/512.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png index 2188fe54..8d15af57 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128.png b/Assets.xcassets/AppIcon.appiconset/128.png index b458571a..713a81f1 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/128.png and b/Assets.xcassets/AppIcon.appiconset/128.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128@2x.png b/Assets.xcassets/AppIcon.appiconset/128@2x.png index 158d4b64..7028d73c 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/128@2x.png and b/Assets.xcassets/AppIcon.appiconset/128@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png index 7a4090cb..2fd855c2 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png and b/Assets.xcassets/AppIcon.appiconset/128@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/128_dark.png b/Assets.xcassets/AppIcon.appiconset/128_dark.png index c5bf91f7..126aae76 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/128_dark.png and b/Assets.xcassets/AppIcon.appiconset/128_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16.png b/Assets.xcassets/AppIcon.appiconset/16.png index 43570df5..f7fc3199 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/16.png and b/Assets.xcassets/AppIcon.appiconset/16.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16@2x.png b/Assets.xcassets/AppIcon.appiconset/16@2x.png index 1e3fd85b..ae5aa984 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/16@2x.png and b/Assets.xcassets/AppIcon.appiconset/16@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png index 3b0996a9..b682b7d5 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png and b/Assets.xcassets/AppIcon.appiconset/16@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/16_dark.png b/Assets.xcassets/AppIcon.appiconset/16_dark.png index 5c08817d..d861db54 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/16_dark.png and b/Assets.xcassets/AppIcon.appiconset/16_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256.png b/Assets.xcassets/AppIcon.appiconset/256.png index 37255441..7028d73c 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/256.png and b/Assets.xcassets/AppIcon.appiconset/256.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256@2x.png b/Assets.xcassets/AppIcon.appiconset/256@2x.png index 52e0e222..b3393bcd 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/256@2x.png and b/Assets.xcassets/AppIcon.appiconset/256@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png index 1ae50d6b..9de53249 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png and b/Assets.xcassets/AppIcon.appiconset/256@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/256_dark.png b/Assets.xcassets/AppIcon.appiconset/256_dark.png index 7a4090cb..2fd855c2 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/256_dark.png and b/Assets.xcassets/AppIcon.appiconset/256_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32.png b/Assets.xcassets/AppIcon.appiconset/32.png index 1e3fd85b..ae5aa984 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/32.png and b/Assets.xcassets/AppIcon.appiconset/32.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32@2x.png b/Assets.xcassets/AppIcon.appiconset/32@2x.png index c97a8c72..e9ec63c6 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/32@2x.png and b/Assets.xcassets/AppIcon.appiconset/32@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png index a952979d..df4110fa 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png and b/Assets.xcassets/AppIcon.appiconset/32@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/32_dark.png b/Assets.xcassets/AppIcon.appiconset/32_dark.png index 3b0996a9..b682b7d5 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/32_dark.png and b/Assets.xcassets/AppIcon.appiconset/32_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512.png b/Assets.xcassets/AppIcon.appiconset/512.png index 52e0e222..b3393bcd 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/512.png and b/Assets.xcassets/AppIcon.appiconset/512.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512@2x.png b/Assets.xcassets/AppIcon.appiconset/512@2x.png index 5a099e36..847feeb5 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/512@2x.png and b/Assets.xcassets/AppIcon.appiconset/512@2x.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png b/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png index 08e06a8f..83b79438 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png and b/Assets.xcassets/AppIcon.appiconset/512@2x_dark.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/512_dark.png b/Assets.xcassets/AppIcon.appiconset/512_dark.png index 1ae50d6b..9de53249 100644 Binary files a/Assets.xcassets/AppIcon.appiconset/512_dark.png and b/Assets.xcassets/AppIcon.appiconset/512_dark.png differ diff --git a/Assets.xcassets/AppIconDark.imageset/AppIconDark.png b/Assets.xcassets/AppIconDark.imageset/AppIconDark.png index 08e06a8f..83b79438 100644 Binary files a/Assets.xcassets/AppIconDark.imageset/AppIconDark.png and b/Assets.xcassets/AppIconDark.imageset/AppIconDark.png differ diff --git a/Assets.xcassets/AppIconLight.imageset/AppIconLight.png b/Assets.xcassets/AppIconLight.imageset/AppIconLight.png index 5a099e36..847feeb5 100644 Binary files a/Assets.xcassets/AppIconLight.imageset/AppIconLight.png and b/Assets.xcassets/AppIconLight.imageset/AppIconLight.png differ diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ece4ea09..fade9fc0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -91,6 +91,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; + A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; /* End PBXBuildFile section */ @@ -207,6 +208,7 @@ B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; }; D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/open"; sourceTree = SOURCE_ROOT; }; @@ -230,6 +232,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; + A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; /* End PBXFileReference section */ @@ -328,6 +331,7 @@ B9000003A1B2C3D4E5F60719 /* CLI */, 087C454FFF74443AB06942C3 /* Resources */, A5001101 /* Assets.xcassets */, + IC000002 /* AppIcon.icon */, A5001016 /* GhosttyKit.xcframework */, A5001017 /* ghostty.h */, A5001018 /* cmux-Bridging-Header.h */, @@ -456,6 +460,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, + A5008382 /* CommandPaletteSearchEngineTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -691,6 +696,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, + A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/README.md b/README.md index a7629aa9..93e896bc 100644 --- a/README.md +++ b/README.md @@ -237,10 +237,10 @@ cmux does **not** restore live process state inside terminal apps. For example, Ways to get involved: -- Follow us on X for updates [@manaflowai](https://x.com/manaflowai) or [@lawrencecchen](https://x.com/lawrencecchen) +- Follow us on X for updates [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), and [@austinywang](https://x.com/austinywang) - Join the conversation on [Discord](https://discord.gg/xsgFEVrWCZ) - Create and participate in [GitHub issues](https://github.com/manaflow-ai/cmux/issues) and [discussions](https://github.com/manaflow-ai/cmux/discussions) -- Let me know what you're building with cmux +- Let us know what you're building with cmux ## Community diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index dd1a936a..d568c9b3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -72685,6 +72685,40 @@ } } } + }, + "markdown.fileUnavailable.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file may have been moved or deleted." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルが移動または削除された可能性があります。" + } + } + } + }, + "markdown.fileUnavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを利用できません" + } + } + } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9b2f218e..28b80d6f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3713,7 +3713,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var refreshedCount = 0 forEachTerminalPanel { terminalPanel in terminalPanel.hostedView.reconcileGeometryNow() - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") refreshedCount += 1 } #if DEBUG @@ -5510,7 +5510,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - guard let self else { return } + guard self != nil else { return } runSetupWhenWindowReady() } } @@ -5607,6 +5607,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "ghosttyGotoSplitDownShortcut": ghosttyGotoSplitDownShortcut?.displayString ?? "", "webViewFocused": "true" ]) + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" { + setupFocusedInputForGotoSplitUITest(panel: browserPanel) + } return } @@ -5652,6 +5655,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let panelId = notification.object as? UUID else { return } self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarFocus") + self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarFocus") }) gotoSplitUITestObservers.append(NotificationCenter.default.addObserver( @@ -5662,6 +5666,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let panelId = notification.object as? UUID else { return } self.recordGotoSplitUITestWebViewFocus(panelId: panelId, key: "webViewFocusedAfterAddressBarExit") + self.recordGotoSplitUITestActiveElement(panelId: panelId, keyPrefix: "addressBarExit") }) } @@ -5689,6 +5694,329 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel, attempt: Int = 0) { + let maxAttempts = 80 + guard attempt < maxAttempts else { + writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "setupError": "Timed out focusing page input for omnibar restore test" + ]) + return + } + + let script = """ + (() => { + try { + const trackerInstalled = window.__cmuxAddressBarFocusTrackerInstalled === true; + const readyState = String(document.readyState || ""); + if (!trackerInstalled || readyState !== "complete") { + const active = document.activeElement; + return { + focused: false, + id: "", + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState + }; + } + + const ensureInput = (id, value) => { + const existing = document.getElementById(id); + const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input") + ? existing + : (() => { + const created = document.createElement("input"); + created.id = id; + created.type = "text"; + created.value = value; + return created; + })(); + input.autocapitalize = "off"; + input.autocomplete = "off"; + input.spellcheck = false; + input.style.display = "block"; + input.style.width = "100%"; + input.style.margin = "0"; + input.style.padding = "8px 10px"; + input.style.border = "1px solid #5f6368"; + input.style.borderRadius = "6px"; + input.style.boxSizing = "border-box"; + input.style.fontSize = "14px"; + input.style.fontFamily = "system-ui, -apple-system, sans-serif"; + input.style.background = "white"; + input.style.color = "black"; + return input; + }; + + let container = document.getElementById("cmux-ui-test-focus-container"); + if (!container || !container.tagName || container.tagName.toLowerCase() !== "div") { + container = document.createElement("div"); + container.id = "cmux-ui-test-focus-container"; + document.body.appendChild(container); + } + container.style.position = "fixed"; + container.style.left = "24px"; + container.style.top = "24px"; + container.style.width = "min(520px, calc(100vw - 48px))"; + container.style.display = "grid"; + container.style.rowGap = "12px"; + container.style.padding = "12px"; + container.style.background = "rgba(255,255,255,0.92)"; + container.style.border = "1px solid rgba(95,99,104,0.55)"; + container.style.borderRadius = "8px"; + container.style.boxShadow = "0 2px 10px rgba(0,0,0,0.2)"; + container.style.zIndex = "2147483647"; + + const input = ensureInput("cmux-ui-test-focus-input", "cmux-ui-focus-primary"); + const secondaryInput = ensureInput("cmux-ui-test-focus-input-secondary", "cmux-ui-focus-secondary"); + if (input.parentElement !== container) { + container.appendChild(input); + } + if (secondaryInput.parentElement !== container) { + container.appendChild(secondaryInput); + } + + input.focus({ preventScroll: true }); + if (typeof input.setSelectionRange === "function") { + const end = input.value.length; + input.setSelectionRange(end, end); + } + + let trackedFocusId = input.getAttribute("data-cmux-addressbar-focus-id"); + if (!trackedFocusId) { + trackedFocusId = "cmux-ui-test-focus-input-tracked"; + input.setAttribute("data-cmux-addressbar-focus-id", trackedFocusId); + } + const selectionStart = typeof input.selectionStart === "number" ? input.selectionStart : null; + const selectionEnd = typeof input.selectionEnd === "number" ? input.selectionEnd : null; + if ( + !window.__cmuxAddressBarFocusState || + typeof window.__cmuxAddressBarFocusState.id !== "string" || + window.__cmuxAddressBarFocusState.id !== trackedFocusId + ) { + window.__cmuxAddressBarFocusState = { id: trackedFocusId, selectionStart, selectionEnd }; + } + + const secondaryRect = secondaryInput.getBoundingClientRect(); + const viewportWidth = Math.max(Number(window.innerWidth) || 0, 1); + const viewportHeight = Math.max(Number(window.innerHeight) || 0, 1); + const secondaryCenterX = Math.min( + 0.98, + Math.max(0.02, (secondaryRect.left + (secondaryRect.width / 2)) / viewportWidth) + ); + const secondaryCenterY = Math.min( + 0.98, + Math.max(0.02, (secondaryRect.top + (secondaryRect.height / 2)) / viewportHeight) + ); + const active = document.activeElement; + return { + focused: active === input, + id: input.id || "", + secondaryId: secondaryInput.id || "", + secondaryCenterX, + secondaryCenterY, + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState + }; + } catch (_) { + return { + focused: false, + id: "", + secondaryId: "", + secondaryCenterX: -1, + secondaryCenterY: -1, + activeId: "", + activeTag: "", + trackerInstalled: false, + trackedStateId: "", + readyState: "" + }; + } + })(); + """ + + panel.webView.evaluateJavaScript(script) { [weak self] result, _ in + guard let self else { return } + let payload = result as? [String: Any] + let focused = (payload?["focused"] as? Bool) ?? false + let inputId = (payload?["id"] as? String) ?? "" + let secondaryInputId = (payload?["secondaryId"] as? String) ?? "" + let secondaryCenterX = (payload?["secondaryCenterX"] as? NSNumber)?.doubleValue ?? -1 + let secondaryCenterY = (payload?["secondaryCenterY"] as? NSNumber)?.doubleValue ?? -1 + let activeId = (payload?["activeId"] as? String) ?? "" + let trackerInstalled = (payload?["trackerInstalled"] as? Bool) ?? false + let trackedStateId = (payload?["trackedStateId"] as? String) ?? "" + let readyState = (payload?["readyState"] as? String) ?? "" + var secondaryClickOffsetX = -1.0 + var secondaryClickOffsetY = -1.0 + if let window = panel.webView.window { + let webFrame = panel.webView.convert(panel.webView.bounds, to: nil) + let contentHeight = Double(window.contentView?.bounds.height ?? 0) + if webFrame.width > 1, + webFrame.height > 1, + contentHeight > 1, + secondaryCenterX > 0, + secondaryCenterX < 1, + secondaryCenterY > 0, + secondaryCenterY < 1 { + let xInContent = Double(webFrame.minX) + (secondaryCenterX * Double(webFrame.width)) + let yFromTopInWeb = secondaryCenterY * Double(webFrame.height) + let yInContent = Double(webFrame.maxY) - yFromTopInWeb + let yFromTopInContent = contentHeight - yInContent + let titlebarHeight = max(0, Double(window.frame.height) - contentHeight) + secondaryClickOffsetX = xInContent + secondaryClickOffsetY = titlebarHeight + yFromTopInContent + } + } + if focused, + !inputId.isEmpty, + !secondaryInputId.isEmpty, + inputId == activeId, + trackerInstalled, + !trackedStateId.isEmpty, + secondaryCenterX > 0, + secondaryCenterX < 1, + secondaryCenterY > 0, + secondaryCenterY < 1, + secondaryClickOffsetX > 0, + secondaryClickOffsetY > 0 { + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "true", + "webInputFocusElementId": inputId, + "webInputFocusSecondaryElementId": secondaryInputId, + "webInputFocusSecondaryCenterX": "\(secondaryCenterX)", + "webInputFocusSecondaryCenterY": "\(secondaryCenterY)", + "webInputFocusSecondaryClickOffsetX": "\(secondaryClickOffsetX)", + "webInputFocusSecondaryClickOffsetY": "\(secondaryClickOffsetY)", + "webInputFocusActiveElementId": activeId, + "webInputFocusTrackerInstalled": trackerInstalled ? "true" : "false", + "webInputFocusTrackedStateId": trackedStateId, + "webInputFocusReadyState": readyState + ]) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.setupFocusedInputForGotoSplitUITest(panel: panel, attempt: attempt + 1) + } + } + } + + private func recordGotoSplitUITestActiveElement(panelId: UUID, keyPrefix: String) { + recordGotoSplitUITestActiveElementRetry(panelId: panelId, keyPrefix: keyPrefix, attempt: 0) + } + + private func recordGotoSplitUITestActiveElementRetry(panelId: UUID, keyPrefix: String, attempt: Int) { + let delays: [Double] = [0.05, 0.1, 0.25, 0.5] + let delay = attempt < delays.count ? delays[attempt] : delays.last! + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, + let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { return } + + self.evaluateGotoSplitUITestActiveElement(panel: panel) { snapshot in + let activeId = snapshot["id"] ?? "" + let expectedInputId = self.gotoSplitUITestExpectedInputId() ?? "" + if keyPrefix == "addressBarExit", + !expectedInputId.isEmpty, + activeId != expectedInputId, + attempt < delays.count - 1 { + self.recordGotoSplitUITestActiveElementRetry( + panelId: panelId, + keyPrefix: keyPrefix, + attempt: attempt + 1 + ) + return + } + + self.writeGotoSplitTestData([ + "\(keyPrefix)PanelId": panelId.uuidString, + "\(keyPrefix)ActiveElementId": activeId, + "\(keyPrefix)ActiveElementTag": snapshot["tag"] ?? "", + "\(keyPrefix)ActiveElementType": snapshot["type"] ?? "", + "\(keyPrefix)ActiveElementEditable": snapshot["editable"] ?? "false", + "\(keyPrefix)TrackedFocusStateId": snapshot["trackedFocusStateId"] ?? "", + "\(keyPrefix)FocusTrackerInstalled": snapshot["focusTrackerInstalled"] ?? "false" + ]) + } + } + } + + private func evaluateGotoSplitUITestActiveElement( + panel: BrowserPanel, + completion: @escaping ([String: String]) -> Void + ) { + let script = """ + (() => { + try { + const active = document.activeElement; + if (!active) { + return { id: "", tag: "", type: "", editable: "false" }; + } + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const editable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + return { + id: typeof active.id === "string" ? active.id : "", + tag, + type, + editable: editable ? "true" : "false", + trackedFocusStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + focusTrackerInstalled: + window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } catch (_) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: "false" + }; + } + })(); + """ + + panel.webView.evaluateJavaScript(script) { result, _ in + let payload = result as? [String: Any] + completion([ + "id": (payload?["id"] as? String) ?? "", + "tag": (payload?["tag"] as? String) ?? "", + "type": (payload?["type"] as? String) ?? "", + "editable": (payload?["editable"] as? String) ?? "false", + "trackedFocusStateId": (payload?["trackedFocusStateId"] as? String) ?? "", + "focusTrackerInstalled": (payload?["focusTrackerInstalled"] as? String) ?? "false" + ]) + } + } + + private func gotoSplitUITestExpectedInputId() -> String? { + let env = ProcessInfo.processInfo.environment + guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil } + return loadGotoSplitTestData(at: path)["webInputFocusElementId"] + } + private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) { guard isGotoSplitUITestRecordingEnabled() else { return } guard let tabManager, let workspace = tabManager.selectedWorkspace else { return } @@ -6591,7 +6919,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if browserAddressBarFocusedPanelId != nil, cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG - dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") + let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + + "reason=terminal_first_responder fr=\(firstResponderType)" + ) #endif browserAddressBarFocusedPanelId = nil stopBrowserOmnibarSelectionRepeat() @@ -7274,6 +7607,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } dlog(line) } + + private func browserFocusStateSnapshot() -> String { + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" + } + + private func redactedDebugURL(_ url: URL?) -> String { + guard let url else { return "nil" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "" + } + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string ?? "" + } #endif @discardableResult @@ -7281,9 +7635,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let tabManager, let workspace = tabManager.selectedWorkspace, let panel = workspace.browserPanel(for: panelId) else { +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + + "result=miss \(browserFocusStateSnapshot())" + ) +#endif return false } +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" + ) +#endif workspace.focusPanel(panel.id) +#if DEBUG + let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" + ) +#endif focusBrowserAddressBar(in: panel) return true } @@ -7291,16 +7664,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else { +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + + "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" + ) +#endif return nil } +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + + "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" + ) +#endif +#if DEBUG + let didFocus = focusBrowserAddressBar(panelId: panelId) + dlog( + "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + + "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" + ) +#else _ = focusBrowserAddressBar(panelId: panelId) +#endif return panelId } private func focusBrowserAddressBar(in panel: BrowserPanel) { +#if DEBUG + let requestId = panel.requestAddressBarFocus() + dlog( + "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#else _ = panel.requestAddressBarFocus() +#endif browserAddressBarFocusedPanelId = panel.id +#if DEBUG + dlog( + "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#endif NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) +#if DEBUG + dlog( + "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8))" + ) +#endif } func focusedBrowserAddressBarPanelId() -> UUID? { @@ -7309,11 +7722,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { guard let panelId = browserAddressBarFocusedPanelId else { return nil } - guard let context = preferredMainWindowContextForShortcutRouting(event: event), - let workspace = context.tabManager.selectedWorkspace, - workspace.browserPanel(for: panelId) != nil else { + + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + ) +#endif return nil } + + guard let workspace = context.tabManager.selectedWorkspace else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } + + guard workspace.browserPanel(for: panelId) != nil else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + + "event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } + +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" + ) +#endif return panelId } @@ -7330,7 +7776,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] guard isCommandOrControlOnly else { return false } - return chars == "n" || chars == "p" + let shouldBypass = chars == "n" || chars == "p" +#if DEBUG + if shouldBypass { + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + + "chars=\(chars) flags=\(normalizedFlags.rawValue)" + ) + } +#endif + return shouldBypass } private func commandOmnibarSelectionDelta( @@ -7347,6 +7803,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func dispatchBrowserOmnibarSelectionMove(delta: Int) { guard delta != 0 else { return } guard let panelId = browserAddressBarFocusedPanelId else { return } +#if DEBUG + dlog( + "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + + "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" + ) +#endif NotificationCenter.default.post( name: .browserMoveOmnibarSelection, object: panelId, @@ -7356,15 +7818,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { guard delta != 0 else { return } - guard browserAddressBarFocusedPanelId != nil else { return } + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + + "result=skip_no_focused_address_bar" + ) +#endif + return + } if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=reuse" + ) +#endif return } stopBrowserOmnibarSelectionRepeat() browserOmnibarRepeatKeyCode = keyCode browserOmnibarRepeatDelta = delta +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=armed" + ) +#endif let start = DispatchWorkItem { [weak self] in self?.scheduleBrowserOmnibarSelectionRepeatTick() @@ -7376,11 +7860,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func scheduleBrowserOmnibarSelectionRepeatTick() { browserOmnibarRepeatStartWorkItem = nil guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") +#endif stopBrowserOmnibarSelectionRepeat() return } guard browserOmnibarRepeatKeyCode != nil else { return } +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + + "delta=\(browserOmnibarRepeatDelta)" + ) +#endif dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) let tick = DispatchWorkItem { [weak self] in @@ -7391,12 +7885,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func stopBrowserOmnibarSelectionRepeat() { +#if DEBUG + let previousKeyCode = browserOmnibarRepeatKeyCode + let previousDelta = browserOmnibarRepeatDelta +#endif browserOmnibarRepeatStartWorkItem?.cancel() browserOmnibarRepeatTickWorkItem?.cancel() browserOmnibarRepeatStartWorkItem = nil browserOmnibarRepeatTickWorkItem = nil browserOmnibarRepeatKeyCode = nil browserOmnibarRepeatDelta = 0 +#if DEBUG + if previousKeyCode != nil || previousDelta != 0 { + dlog( + "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + + "delta=\(previousDelta)" + ) + } +#endif } private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { @@ -7405,11 +7911,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch event.type { case .keyUp: if event.keyCode == browserOmnibarRepeatKeyCode { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + + "action=stop" + ) +#endif stopBrowserOmnibarSelectionRepeat() } case .flagsChanged: let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if !flags.contains(.command) { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + + "flags=\(flags.rawValue) action=stop" + ) +#endif stopBrowserOmnibarSelectionRepeat() } default: @@ -9180,6 +9698,9 @@ enum MenuBarIconRenderer { private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent? private var cmuxFirstResponderGuardHitViewOverride: NSView? #endif +private var cmuxFirstResponderGuardCurrentEventContext: NSEvent? +private var cmuxFirstResponderGuardHitViewContext: NSView? +private var cmuxFirstResponderGuardContextWindowNumber: Int? private var cmuxBrowserReturnForwardingDepth = 0 private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -9221,6 +9742,7 @@ private extension NSWindow { let responderWebView = responder.flatMap { Self.cmuxOwningWebView(for: $0, in: self, event: currentEvent) } + var pointerInitiatedWebFocus = false if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( window: self, @@ -9244,6 +9766,7 @@ private extension NSWindow { event: currentEvent ) if pointerInitiatedFocus { + pointerInitiatedWebFocus = true #if DEBUG dlog( "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + @@ -9280,7 +9803,16 @@ private extension NSWindow { ) } #endif - let result = cmux_makeFirstResponder(responder) + let result: Bool + if pointerInitiatedWebFocus, let webView = responderWebView { + // `NSWindow.makeFirstResponder` may run before `CmuxWebView.mouseDown(with:)`. + // Preserve pointer intent during this synchronous responder change. + result = webView.withPointerFocusAllowance { + cmux_makeFirstResponder(responder) + } + } else { + result = cmux_makeFirstResponder(responder) + } if result { if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView) @@ -9292,6 +9824,18 @@ private extension NSWindow { } @objc func cmux_sendEvent(_ event: NSEvent) { + let previousContextEvent = cmuxFirstResponderGuardCurrentEventContext + let previousContextHitView = cmuxFirstResponderGuardHitViewContext + let previousContextWindowNumber = cmuxFirstResponderGuardContextWindowNumber + cmuxFirstResponderGuardCurrentEventContext = event + cmuxFirstResponderGuardHitViewContext = Self.cmuxHitViewForEventDispatch(in: self, event: event) + cmuxFirstResponderGuardContextWindowNumber = self.windowNumber + defer { + cmuxFirstResponderGuardCurrentEventContext = previousContextEvent + cmuxFirstResponderGuardHitViewContext = previousContextHitView + cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber + } + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), let contentView = self.contentView else { cmux_sendEvent(event) @@ -9549,37 +10093,63 @@ private extension NSWindow { return found } - private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? { + private static func cmuxCurrentEvent(for window: NSWindow) -> NSEvent? { #if DEBUG if let override = cmuxFirstResponderGuardCurrentEventOverride { return override } #endif + if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber { + return cmuxFirstResponderGuardCurrentEventContext + } return NSApp.currentEvent } + private static func cmuxHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) + return themeFrame.hitTest(pointInTheme) + } + + private static func cmuxHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView else { + return nil + } + let pointInContent = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(pointInContent) + } + + private static func cmuxTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { + if let hitInThemeFrame = cmuxHitViewInThemeFrame(in: window, event: event) { + return hitInThemeFrame + } + return cmuxHitViewInContentView(in: window, event: event) + } + + private static func cmuxHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + return cmuxTopHitViewForEvent(in: window, event: event) + } + private static func cmuxHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { #if DEBUG if let override = cmuxFirstResponderGuardHitViewOverride { return override } #endif - guard let contentView = window.contentView else { return nil } - - if contentView.className == "NSGlassEffectView" { - let pointInContent = contentView.convert(event.locationInWindow, from: nil) - return contentView.hitTest(pointInContent) + if cmuxFirstResponderGuardContextWindowNumber == window.windowNumber, + let contextHitView = cmuxFirstResponderGuardHitViewContext { + return contextHitView } - - if let themeFrame = contentView.superview { - let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) - if let hit = themeFrame.hitTest(pointInTheme) { - return hit - } - } - - let pointInContent = contentView.convert(event.locationInWindow, from: nil) - return contentView.hitTest(pointInContent) + return cmuxTopHitViewForEvent(in: window, event: event) } private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) { diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index cb12b170..597efdb4 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -70,6 +70,13 @@ final class WindowBrowserHostView: NSView { private var activeDividerCursorKind: DividerCursorKind? private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? + deinit { + if let trackingArea { + removeTrackingArea(trackingArea) + } + clearActiveDividerCursor(restoreArrow: false) + } + #if DEBUG private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool { switch event?.type { @@ -1765,6 +1772,20 @@ final class WindowBrowserPortal: NSObject { ) } + private static func searchOverlayConfigurationsEquivalent( + _ lhs: BrowserPortalSearchOverlayConfiguration?, + _ rhs: BrowserPortalSearchOverlayConfiguration? + ) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (lhs?, rhs?): + return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState + default: + return false + } + } + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. /// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the /// visible split pane during rearrangement; intersecting through ancestor bounds keeps the @@ -1953,6 +1974,7 @@ final class WindowBrowserPortal: NSObject { /// do not keep an old anchor visible. func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.visibleInUI != visibleInUI || entry.zPriority != zPriority else { return } entry.visibleInUI = visibleInUI entry.zPriority = zPriority entriesByWebViewId[webViewId] = entry @@ -1968,6 +1990,7 @@ final class WindowBrowserPortal: NSObject { func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.dropZone != zone else { return } entry.dropZone = zone entriesByWebViewId[webViewId] = entry entry.containerView?.setDropZoneOverlay(zone: zone) @@ -1975,6 +1998,7 @@ final class WindowBrowserPortal: NSObject { func updatePaneDropContext(forWebViewId webViewId: ObjectIdentifier, context: BrowserPaneDropContext?) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard entry.paneDropContext != context else { return } entry.paneDropContext = context entriesByWebViewId[webViewId] = entry entry.containerView?.setPaneDropContext(context) @@ -1985,6 +2009,7 @@ final class WindowBrowserPortal: NSObject { configuration: BrowserPortalSearchOverlayConfiguration? ) { guard var entry = entriesByWebViewId[webViewId] else { return } + guard !Self.searchOverlayConfigurationsEquivalent(entry.searchOverlay, configuration) else { return } entry.searchOverlay = configuration entriesByWebViewId[webViewId] = entry entry.containerView?.setSearchOverlay(configuration) @@ -2263,6 +2288,28 @@ final class WindowBrowserPortal: NSObject { return } guard anchorView.window === window else { + let isOffWindowReparent = + entry.visibleInUI && + anchorView.window == nil && + anchorView.superview != nil + if isOffWindowReparent { + let didScheduleTransientRecovery = scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: "anchorWindowMismatch" + ) +#if DEBUG + if didScheduleTransientRecovery && !containerView.isHidden { + dlog( + "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + + "reason=anchorWindowMismatch.offWindow frame=\(browserPortalDebugFrame(containerView.frame))" + ) + } +#endif + containerView.setDropZoneOverlay(zone: nil) + return + } if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") { containerView.setPaneTopChromeHeight(0) containerView.setSearchOverlay(nil) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 711d7ae6..a8852325 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1309,10 +1309,28 @@ struct ContentView: View { @State private var commandPaletteMode: CommandPaletteMode = .commands @State private var commandPaletteRenameDraft: String = "" @State private var commandPaletteSelectedResultIndex: Int = 0 + @State private var commandPaletteSelectionAnchorCommandID: String? @State private var commandPaletteHoveredResultIndex: Int? @State private var commandPaletteScrollTargetIndex: Int? @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] + @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] + @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] + @State private var commandPaletteVisibleResults: [CommandPaletteSearchResult] = [] + @State private var commandPaletteVisibleResultsScope: CommandPaletteListScope? + @State private var commandPaletteVisibleResultsFingerprint: Int? + @State private var cachedCommandPaletteScope: CommandPaletteListScope? + @State private var cachedCommandPaletteFingerprint: Int? + @State private var commandPaletteSearchTask: Task? + @State private var commandPaletteSearchRequestID: UInt64 = 0 + @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 + @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? + @State private var commandPaletteResolvedSearchFingerprint: Int? + @State private var isCommandPaletteSearchPending = false + @State private var commandPalettePendingActivation: CommandPalettePendingActivation? + @State private var commandPaletteResultsRevision: UInt64 = 0 @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @State private var isFeedbackComposerPresented = false @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) @@ -1333,6 +1351,16 @@ struct ContentView: View { case switcher } + enum CommandPalettePendingActivation: Equatable { + case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) + case command(requestID: UInt64, commandID: String) + } + + enum CommandPaletteResolvedActivation: Equatable { + case selected(index: Int) + case command(commandID: String) + } + private struct CommandPaletteRenameTarget: Equatable { enum Kind: Equatable { case workspace(workspaceId: UUID) @@ -1426,7 +1454,7 @@ struct ContentView: View { } } - private struct CommandPaletteUsageEntry: Codable { + private struct CommandPaletteUsageEntry: Codable, Sendable { var useCount: Int var lastUsedAt: TimeInterval } @@ -1454,6 +1482,13 @@ struct ContentView: View { func string(_ key: String) -> String? { stringValues[key] } + + func fingerprint() -> Int { + ContentView.commandPaletteContextFingerprint( + boolValues: boolValues, + stringValues: stringValues + ) + } } private enum CommandPaletteContextKeys { @@ -1530,6 +1565,12 @@ struct ContentView: View { var id: String { command.id } } + private struct CommandPaletteResolvedSearchMatch: Sendable { + let commandID: String + let score: Int + let titleMatchIndices: Set + } + private struct CommandPaletteSwitcherWindowContext { let windowId: UUID let tabManager: TabManager @@ -1537,12 +1578,27 @@ struct ContentView: View { let windowLabel: String? } + struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { + let id: UUID + let displayName: String + let metadata: CommandPaletteSwitcherSearchMetadata + } + + struct CommandPaletteSwitcherFingerprintContext: Sendable { + let windowId: UUID + let windowLabel: String? + let selectedWorkspaceId: UUID? + let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] + } + private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot ) private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" private static let commandPaletteCommandsPrefix = ">" + private static let commandPaletteVisiblePreviewResultLimit = 48 + private static let commandPaletteVisiblePreviewCandidateLimit = 192 private static let minimumSidebarWidth: CGFloat = 186 private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 @@ -2892,7 +2948,7 @@ struct ContentView: View { } private var commandPaletteCommandListView: some View { - let visibleResults = Array(commandPaletteResults) + let visibleResults = commandPaletteVisibleResults let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 @@ -2910,7 +2966,7 @@ struct ContentView: View { .focused($isCommandPaletteSearchFocused) .accessibilityIdentifier("CommandPaletteSearchField") .onSubmit { - runSelectedCommandPaletteResult(visibleResults: visibleResults) + runSelectedCommandPaletteResult() } .backport.onKeyPress(.downArrow) { _ in moveCommandPaletteSelection(by: 1) @@ -2932,7 +2988,6 @@ struct ContentView: View { .backport.onKeyPress("k") { modifiers in handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) } - } .padding(.horizontal, 9) .padding(.vertical, 7) @@ -2942,12 +2997,18 @@ struct ContentView: View { ScrollView { LazyVStack(spacing: 0) { if visibleResults.isEmpty { - Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 12) + if commandPaletteHasCurrentResolvedResults { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: commandPaletteEmptyStateHeight) + } } else { ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in let isSelected = index == selectedIndex @@ -2957,7 +3018,7 @@ struct ContentView: View { : (isHovered ? Color.primary.opacity(0.08) : .clear) Button { - runCommandPaletteCommand(result.command) + runCommandPaletteResult(commandID: result.id) } label: { HStack(spacing: 8) { commandPaletteHighlightedTitleText( @@ -3032,20 +3093,35 @@ struct ContentView: View { } .onAppear { commandPaletteHoveredResultIndex = nil - updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) resetCommandPaletteSearchFocus() } .onChange(of: commandPaletteQuery) { _ in commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil + scheduleCommandPaletteResultsRefresh() + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) syncCommandPaletteDebugStateForObservedWindow() } - .onChange(of: visibleResults.count) { _ in - commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) - if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { + .onChange(of: commandPaletteCurrentSearchFingerprint) { _ in + scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: commandPaletteResultsRevision) { _ in + let resultIDs = cachedCommandPaletteResults.map(\.id) + commandPaletteSelectedResultIndex = Self.commandPaletteResolvedSelectionIndex( + preferredCommandID: commandPaletteSelectionAnchorCommandID, + fallbackSelectedIndex: commandPaletteSelectedResultIndex, + resultIDs: resultIDs + ) + syncCommandPaletteSelectionAnchorFromCurrentResults() + let visibleResultCount = commandPaletteVisibleResults.count + updateCommandPaletteScrollTarget(resultCount: visibleResultCount, animated: false) + if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResultCount { commandPaletteHoveredResultIndex = nil } syncCommandPaletteDebugStateForObservedWindow() @@ -3164,6 +3240,10 @@ struct ContentView: View { return .switcher } + private var commandPaletteCurrentSearchFingerprint: Int { + commandPaletteEntriesFingerprint(for: commandPaletteListScope) + } + private var commandPaletteSearchPlaceholder: String { switch commandPaletteListScope { case .commands: @@ -3192,8 +3272,8 @@ struct ContentView: View { } } - private var commandPaletteEntries: [CommandPaletteCommand] { - switch commandPaletteListScope { + private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] { + switch scope { case .commands: return commandPaletteCommands() case .switcher: @@ -3201,39 +3281,360 @@ struct ContentView: View { } } - private var commandPaletteResults: [CommandPaletteSearchResult] { - let entries = commandPaletteEntries + private func refreshCommandPaletteSearchCorpus(force: Bool = false) { + let scope = commandPaletteListScope + let fingerprint = commandPaletteEntriesFingerprint(for: scope) + guard force || cachedCommandPaletteScope != scope || cachedCommandPaletteFingerprint != fingerprint else { + return + } + + let entries = commandPaletteEntries(for: scope) + commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) + let searchCorpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + commandPaletteSearchCorpus = searchCorpus + commandPaletteSearchCorpusByID = Dictionary(uniqueKeysWithValues: searchCorpus.map { ($0.payload, $0) }) + cachedCommandPaletteScope = scope + cachedCommandPaletteFingerprint = fingerprint + } + + private func cancelCommandPaletteSearch() { + commandPaletteSearchTask?.cancel() + commandPaletteSearchTask = nil + } + + nonisolated private static func commandPaletteResolvedSearchMatches( + searchCorpus: [CommandPaletteSearchCorpusEntry], + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval, + shouldCancel: @escaping () -> Bool = { false } + ) -> [CommandPaletteResolvedSearchMatch] { + let results = CommandPaletteSearchEngine.search( + entries: searchCorpus, + query: query, + historyBoost: { commandId, _ in + Self.commandPaletteHistoryBoost( + for: commandId, + queryIsEmpty: queryIsEmpty, + history: usageHistory, + now: historyTimestamp + ) + }, + shouldCancel: shouldCancel + ) + + return results.map { result in + CommandPaletteResolvedSearchMatch( + commandID: result.payload, + score: result.score, + titleMatchIndices: result.titleMatchIndices + ) + } + } + + private static func commandPaletteMaterializedSearchResults( + matches: [CommandPaletteResolvedSearchMatch], + commandsByID: [String: CommandPaletteCommand] + ) -> [CommandPaletteSearchResult] { + matches.compactMap { match in + guard let command = commandsByID[match.commandID] else { return nil } + return CommandPaletteSearchResult( + command: command, + score: match.score, + titleMatchIndices: match.titleMatchIndices + ) + } + } + + private func setCommandPaletteVisibleResults( + _ results: [CommandPaletteSearchResult], + scope: CommandPaletteListScope, + fingerprint: Int? + ) { + commandPaletteVisibleResults = results + commandPaletteVisibleResultsScope = scope + commandPaletteVisibleResultsFingerprint = fingerprint + } + + private func refreshPendingCommandPaletteVisibleResults( + scope: CommandPaletteListScope, + fingerprint: Int?, + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval + ) { + let candidateCommandIDs: [String] + if commandPaletteVisibleResultsScope == scope, + commandPaletteVisibleResultsFingerprint == fingerprint { + candidateCommandIDs = Self.commandPalettePreviewCandidateCommandIDs( + resultIDs: commandPaletteVisibleResults.map(\.id), + limit: Self.commandPaletteVisiblePreviewCandidateLimit + ) + } else { + candidateCommandIDs = [] + } + + let previewMatches = Self.commandPalettePreviewSearchMatches( + scope: scope, + searchCorpus: commandPaletteSearchCorpus, + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: commandPaletteSearchCorpusByID, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp, + resultLimit: Self.commandPaletteVisiblePreviewResultLimit + ) + let previewResults = Self.commandPaletteMaterializedSearchResults( + matches: previewMatches, + commandsByID: commandPaletteSearchCommandsByID + ) + setCommandPaletteVisibleResults( + previewResults, + scope: scope, + fingerprint: fingerprint + ) + } + + nonisolated private static func commandPalettePreviewSearchMatches( + scope: CommandPaletteListScope, + searchCorpus: [CommandPaletteSearchCorpusEntry], + candidateCommandIDs: [String], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval, + resultLimit: Int + ) -> [CommandPaletteResolvedSearchMatch] { + guard resultLimit > 0 else { + return [] + } + + if scope == .commands { + let matches = commandPaletteResolvedSearchMatches( + searchCorpus: searchCorpus, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + guard matches.count > resultLimit else { + return matches + } + return Array(matches.prefix(resultLimit)) + } + + guard !candidateCommandIDs.isEmpty else { + return [] + } + + var seenCommandIDs: Set = [] + let previewEntries: [CommandPaletteSearchCorpusEntry] = candidateCommandIDs.compactMap { commandID in + guard seenCommandIDs.insert(commandID).inserted else { return nil } + return searchCorpusByID[commandID] + } + guard !previewEntries.isEmpty else { + return [] + } + + let matches = commandPaletteResolvedSearchMatches( + searchCorpus: previewEntries, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + guard matches.count > resultLimit else { + return matches + } + return Array(matches.prefix(resultLimit)) + } + + nonisolated static func commandPaletteCommandPreviewMatchCommandIDsForTests( + searchCorpus: [CommandPaletteSearchCorpusEntry], + candidateCommandIDs: [String], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], + query: String, + resultLimit: Int + ) -> [String] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + return commandPalettePreviewSearchMatches( + scope: .commands, + searchCorpus: searchCorpus, + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: searchCorpusByID, + query: query, + usageHistory: [:], + queryIsEmpty: preparedQuery.isEmpty, + historyTimestamp: 0, + resultLimit: resultLimit + ).map(\.commandID) + } + + static func commandPalettePreviewCandidateCommandIDs( + resultIDs: [String], + limit: Int + ) -> [String] { + guard limit > 0 else { return [] } + guard resultIDs.count > limit else { return resultIDs } + return Array(resultIDs.prefix(limit)) + } + + static func commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: Bool + ) -> Bool { + !hasVisibleResultsForScope + } + + private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { + refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) + + commandPaletteSearchRequestID &+= 1 + let requestID = commandPaletteSearchRequestID let query = commandPaletteQueryForMatching - let queryIsEmpty = query.isEmpty + let scope = commandPaletteListScope + let fingerprint = cachedCommandPaletteFingerprint + let searchCorpus = commandPaletteSearchCorpus + let commandsByID = commandPaletteSearchCommandsByID + let usageHistory = commandPaletteUsageHistoryByCommandId + let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty + let historyTimestamp = Date().timeIntervalSince1970 + commandPalettePendingActivation = nil + cancelCommandPaletteSearch() + if Self.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: commandPaletteVisibleResultsScope == scope + ) { + let matches = Self.commandPaletteResolvedSearchMatches( + searchCorpus: searchCorpus, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( + matches: matches, + commandsByID: commandsByID + ) + commandPaletteResolvedSearchRequestID = requestID + commandPaletteResolvedSearchScope = scope + commandPaletteResolvedSearchFingerprint = fingerprint + isCommandPaletteSearchPending = false + setCommandPaletteVisibleResults( + cachedCommandPaletteResults, + scope: scope, + fingerprint: fingerprint + ) + commandPaletteResultsRevision &+= 1 + return + } + refreshPendingCommandPaletteVisibleResults( + scope: scope, + fingerprint: fingerprint, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + isCommandPaletteSearchPending = true - let results: [CommandPaletteSearchResult] = queryIsEmpty - ? entries.map { entry in - CommandPaletteSearchResult( - command: entry, - score: commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: true), - titleMatchIndices: [] - ) - } - : entries.compactMap { entry in - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(query: query, candidates: entry.searchableTexts) else { - return nil + commandPaletteSearchTask = Task.detached(priority: .userInitiated) { + let matches = Self.commandPaletteResolvedSearchMatches( + searchCorpus: searchCorpus, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp, + shouldCancel: { Task.isCancelled } + ) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard commandPaletteSearchRequestID == requestID, + isCommandPalettePresented, + commandPaletteListScope == scope, + commandPaletteQueryForMatching == query, + cachedCommandPaletteFingerprint == fingerprint else { + return } - return CommandPaletteSearchResult( - command: entry, - score: fuzzyScore + commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: false), - titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( - query: query, - candidate: entry.title - ) - ) - } - return results - .sorted { lhs, rhs in - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.command.rank != rhs.command.rank { return lhs.command.rank < rhs.command.rank } - return lhs.command.title.localizedCaseInsensitiveCompare(rhs.command.title) == .orderedAscending + cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( + matches: matches, + commandsByID: commandPaletteSearchCommandsByID + ) + let resultIDs = cachedCommandPaletteResults.map(\.id) + let pendingActivation = commandPalettePendingActivation + let resolvedActivation = Self.commandPaletteResolvedPendingActivation( + pendingActivation, + requestID: requestID, + resultIDs: resultIDs + ) + commandPaletteResolvedSearchRequestID = requestID + commandPaletteResolvedSearchScope = scope + commandPaletteResolvedSearchFingerprint = fingerprint + isCommandPaletteSearchPending = false + setCommandPaletteVisibleResults( + cachedCommandPaletteResults, + scope: scope, + fingerprint: fingerprint + ) + if Self.commandPalettePendingActivationRequestID(pendingActivation) == requestID { + commandPalettePendingActivation = nil + } + commandPaletteResultsRevision &+= 1 + if commandPaletteSearchRequestID == requestID { + commandPaletteSearchTask = nil + } + if let resolvedActivation { + runCommandPaletteResolvedActivation(resolvedActivation) + } } + } + } + + private func commandPaletteEntriesFingerprint(for scope: CommandPaletteListScope) -> Int { + switch scope { + case .commands: + return commandPaletteCommandsFingerprint() + case .switcher: + return commandPaletteSwitcherEntriesFingerprint() + } + } + + private func commandPaletteCommandsFingerprint() -> Int { + var hasher = Hasher() + hasher.combine(commandPaletteContextSnapshot().fingerprint()) + hasher.combine(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false) + return hasher.finalize() + } + + private func commandPaletteSwitcherEntriesFingerprint() -> Int { + let windowContexts = commandPaletteSwitcherWindowContexts() + let fingerprintContexts = windowContexts.map { context in + CommandPaletteSwitcherFingerprintContext( + windowId: context.windowId, + windowLabel: context.windowLabel, + selectedWorkspaceId: context.selectedWorkspaceId, + workspaces: commandPaletteOrderedSwitcherWorkspaces(for: context).map { workspace in + CommandPaletteSwitcherFingerprintWorkspace( + id: workspace.id, + displayName: workspaceDisplayName(workspace), + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace) + ) + } + ) + } + return Self.commandPaletteSwitcherFingerprint(windowContexts: fingerprintContexts) } private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { @@ -3288,16 +3689,9 @@ struct ContentView: View { var nextRank = 0 for context in windowContexts { - var workspaces = context.tabManager.tabs + let workspaces = commandPaletteOrderedSwitcherWorkspaces(for: context) guard !workspaces.isEmpty else { continue } - let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId - if let selectedWorkspaceId, - let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { - let selectedWorkspace = workspaces.remove(at: selectedIndex) - workspaces.insert(selectedWorkspace, at: 0) - } - let windowId = context.windowId let windowTabManager = context.tabManager let windowKeywords = commandPaletteWindowKeywords(windowLabel: context.windowLabel) @@ -3400,6 +3794,22 @@ struct ContentView: View { return ["window", windowLabel.lowercased()] } + private func commandPaletteOrderedSwitcherWorkspaces( + for context: CommandPaletteSwitcherWindowContext + ) -> [Workspace] { + var workspaces = context.tabManager.tabs + guard !workspaces.isEmpty else { return [] } + + let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId + if let selectedWorkspaceId, + let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { + let selectedWorkspace = workspaces.remove(at: selectedIndex) + workspaces.insert(selectedWorkspace, at: 0) + } + + return workspaces + } + private func focusCommandPaletteSwitcherTarget( windowId: UUID, tabManager: TabManager, @@ -4551,6 +4961,116 @@ struct ContentView: View { return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) } + static func commandPaletteResolvedSelectionIndex( + preferredCommandID: String?, + fallbackSelectedIndex: Int, + resultIDs: [String] + ) -> Int { + guard !resultIDs.isEmpty else { return 0 } + if let preferredCommandID, + let anchoredIndex = resultIDs.firstIndex(of: preferredCommandID) { + return anchoredIndex + } + return min(max(fallbackSelectedIndex, 0), resultIDs.count - 1) + } + + static func commandPaletteSelectionAnchorCommandID( + selectedIndex: Int, + resultIDs: [String] + ) -> String? { + guard !resultIDs.isEmpty else { return nil } + let resolvedIndex = min(max(selectedIndex, 0), resultIDs.count - 1) + return resultIDs[resolvedIndex] + } + + static func commandPalettePendingActivationRequestID( + _ pendingActivation: CommandPalettePendingActivation? + ) -> UInt64? { + switch pendingActivation { + case .selected(let requestID, _, _): + return requestID + case .command(let requestID, _): + return requestID + case nil: + return nil + } + } + + static func commandPaletteResolvedPendingActivation( + _ pendingActivation: CommandPalettePendingActivation?, + requestID: UInt64, + resultIDs: [String] + ) -> CommandPaletteResolvedActivation? { + switch pendingActivation { + case .selected(let activationRequestID, let fallbackSelectedIndex, let preferredCommandID): + guard activationRequestID == requestID else { return nil } + let resolvedIndex = commandPaletteResolvedSelectionIndex( + preferredCommandID: preferredCommandID, + fallbackSelectedIndex: fallbackSelectedIndex, + resultIDs: resultIDs + ) + return .selected(index: resolvedIndex) + case .command(let activationRequestID, let commandID): + guard activationRequestID == requestID, resultIDs.contains(commandID) else { return nil } + return .command(commandID: commandID) + case nil: + return nil + } + } + + static func commandPaletteContextFingerprint( + boolValues: [String: Bool], + stringValues: [String: String] + ) -> Int { + var hasher = Hasher() + for key in boolValues.keys.sorted() { + hasher.combine(key) + hasher.combine(boolValues[key] ?? false) + } + for key in stringValues.keys.sorted() { + hasher.combine(key) + hasher.combine(stringValues[key] ?? "") + } + return hasher.finalize() + } + + static func commandPaletteSwitcherFingerprint( + windowContexts: [CommandPaletteSwitcherFingerprintContext] + ) -> Int { + var hasher = Hasher() + hasher.combine(windowContexts.count) + for context in windowContexts { + hasher.combine(context.windowId) + hasher.combine(context.windowLabel) + hasher.combine(context.selectedWorkspaceId) + hasher.combine(context.workspaces.count) + for workspace in context.workspaces { + hasher.combine(workspace.id) + hasher.combine(workspace.displayName) + combineCommandPaletteSwitcherSearchMetadata(workspace.metadata, into: &hasher) + } + } + return hasher.finalize() + } + + static func combineCommandPaletteSwitcherSearchMetadata( + _ metadata: CommandPaletteSwitcherSearchMetadata, + into hasher: inout Hasher + ) { + hasher.combine(metadata.directories.count) + for directory in metadata.directories { + hasher.combine(directory) + } + hasher.combine(metadata.branches.count) + for branch in metadata.branches { + hasher.combine(branch) + } + hasher.combine(metadata.ports.count) + for port in metadata.ports { + hasher.combine(port) + } + } + static func commandPaletteScrollPositionAnchor( selectedIndex: Int, resultCount: Int @@ -4590,14 +5110,34 @@ struct ContentView: View { } } + private func syncCommandPaletteSelectionAnchor(resultIDs: [String]) { + commandPaletteSelectionAnchorCommandID = Self.commandPaletteSelectionAnchorCommandID( + selectedIndex: commandPaletteSelectedResultIndex, + resultIDs: resultIDs + ) + } + + private func syncCommandPaletteSelectionAnchorFromCurrentResults() { + syncCommandPaletteSelectionAnchor(resultIDs: cachedCommandPaletteResults.map(\.id)) + } + + private func syncCommandPaletteSelectionAnchorFromVisibleResults() { + syncCommandPaletteSelectionAnchor(resultIDs: commandPaletteVisibleResults.map(\.id)) + } + private func moveCommandPaletteSelection(by delta: Int) { - let count = commandPaletteResults.count + let count = commandPaletteVisibleResults.count guard count > 0 else { NSSound.beep() return } let current = commandPaletteSelectedIndex(resultCount: count) commandPaletteSelectedResultIndex = min(max(current + delta, 0), count - 1) + if commandPaletteHasCurrentResolvedResults { + syncCommandPaletteSelectionAnchorFromCurrentResults() + } else { + syncCommandPaletteSelectionAnchorFromVisibleResults() + } syncCommandPaletteDebugStateForObservedWindow() } @@ -4654,14 +5194,59 @@ struct ContentView: View { return .handled } - private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) { - let visibleResults = visibleResults ?? Array(commandPaletteResults) - guard !visibleResults.isEmpty else { - NSSound.beep() + private var commandPaletteHasCurrentResolvedResults: Bool { + !isCommandPaletteSearchPending && commandPaletteResolvedSearchRequestID == commandPaletteSearchRequestID + } + + private func runCommandPaletteResolvedActivation(_ activation: CommandPaletteResolvedActivation) { + switch activation { + case .command(let commandID): + guard let command = cachedCommandPaletteResults.first(where: { $0.id == commandID })?.command else { + return + } + runCommandPaletteCommand(command) + case .selected(let fallbackIndex): + guard !cachedCommandPaletteResults.isEmpty else { + NSSound.beep() + return + } + let resolvedIndex = Self.commandPaletteResolvedSelectionIndex( + preferredCommandID: commandPaletteSelectionAnchorCommandID, + fallbackSelectedIndex: fallbackIndex, + resultIDs: cachedCommandPaletteResults.map(\.id) + ) + commandPaletteSelectedResultIndex = resolvedIndex + syncCommandPaletteSelectionAnchorFromCurrentResults() + runCommandPaletteCommand(cachedCommandPaletteResults[resolvedIndex].command) + } + } + + private func runCommandPaletteResult(commandID: String) { + guard commandPaletteHasCurrentResolvedResults else { + if isCommandPalettePresented { + commandPalettePendingActivation = .command( + requestID: commandPaletteSearchRequestID, + commandID: commandID + ) + } return } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - runCommandPaletteCommand(visibleResults[index].command) + runCommandPaletteResolvedActivation(.command(commandID: commandID)) + } + + private func runSelectedCommandPaletteResult() { + guard commandPaletteHasCurrentResolvedResults else { + if isCommandPalettePresented { + commandPalettePendingActivation = .selected( + requestID: commandPaletteSearchRequestID, + fallbackSelectedIndex: commandPaletteSelectedResultIndex, + preferredCommandID: commandPaletteSelectionAnchorCommandID + ) + } + return + } + + runCommandPaletteResolvedActivation(.selected(index: commandPaletteSelectedResultIndex)) } private func handleCommandPaletteSubmitRequest() { @@ -4760,7 +5345,7 @@ struct ContentView: View { private func syncCommandPaletteDebugStateForObservedWindow() { guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) - let visibleResultCount = commandPaletteResults.count + let visibleResultCount = commandPaletteVisibleResults.count let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0 AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window) AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window) @@ -4779,7 +5364,7 @@ struct ContentView: View { mode = "rename_confirm" } - let rows = Array(commandPaletteResults.prefix(20)).map { result in + let rows = Array(commandPaletteVisibleResults.prefix(20)).map { result in CommandPaletteDebugResultRow( commandId: result.command.id, title: result.command.title, @@ -4821,9 +5406,11 @@ struct ContentView: View { commandPaletteQuery = initialQuery commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil + scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } @@ -4837,17 +5424,35 @@ struct ContentView: View { preferredFocusTarget: CommandPaletteRestoreFocusTarget? ) { let focusTarget = preferredFocusTarget ?? commandPaletteRestoreFocusTarget + cancelCommandPaletteSearch() + commandPaletteSearchRequestID &+= 1 isCommandPalettePresented = false commandPaletteMode = .commands commandPaletteQuery = "" commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil isCommandPaletteSearchFocused = false isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil + commandPaletteSearchCorpus = [] + commandPaletteSearchCorpusByID = [:] + commandPaletteSearchCommandsByID = [:] + cachedCommandPaletteResults = [] + commandPaletteVisibleResults = [] + commandPaletteVisibleResultsScope = nil + commandPaletteVisibleResultsFingerprint = nil + cachedCommandPaletteScope = nil + cachedCommandPaletteFingerprint = nil + commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID + commandPaletteResolvedSearchScope = nil + commandPaletteResolvedSearchFingerprint = nil + isCommandPaletteSearchPending = false + commandPalettePendingActivation = nil + commandPaletteResultsRevision &+= 1 if let window = observedWindow { _ = window.makeFirstResponder(nil) } @@ -5121,10 +5726,14 @@ struct ContentView: View { persistCommandPaletteUsageHistory(history) } - private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { - guard let entry = commandPaletteUsageHistoryByCommandId[commandId] else { return 0 } + nonisolated private static func commandPaletteHistoryBoost( + for commandId: String, + queryIsEmpty: Bool, + history: [String: CommandPaletteUsageEntry], + now: TimeInterval + ) -> Int { + guard let entry = history[commandId] else { return 0 } - let now = Date().timeIntervalSince1970 let ageDays = max(0, now - entry.lastUsedAt) / 86_400 let recencyBoost = max(0, 320 - Int(ageDays * 20)) let countBoost = min(180, entry.useCount * 12) @@ -5133,6 +5742,15 @@ struct ContentView: View { return queryIsEmpty ? totalBoost : max(0, totalBoost / 3) } + private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { + Self.commandPaletteHistoryBoost( + for: commandId, + queryIsEmpty: queryIsEmpty, + history: commandPaletteUsageHistoryByCommandId, + now: Date().timeIntervalSince1970 + ) + } + private func beginRenameWorkspaceFlow() { guard let workspace = tabManager.selectedWorkspace else { NSSound.beep() @@ -5336,7 +5954,7 @@ struct ContentView: View { #endif } -struct CommandPaletteSwitcherSearchMetadata { +struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { let directories: [String] let branches: [String] let ports: [Int] @@ -5455,23 +6073,78 @@ enum CommandPaletteSwitcherSearchIndexer { enum CommandPaletteFuzzyMatcher { private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 10 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { + let matchedIndices: Set + let segmentStart: Int + let segmentLength: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind + } + + struct PreparedQuery { + let normalizedText: String + let tokens: [String] + + var isEmpty: Bool { + tokens.isEmpty + } + } + + static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + return PreparedQuery( + normalizedText: normalizedQuery, + tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + ) + } + + static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + static func score(query: String, candidate: String) -> Int? { score(query: query, candidates: [candidate]) } static func score(query: String, candidates: [String]) -> Int? { - let normalizedQuery = normalize(query) - guard !normalizedQuery.isEmpty else { return 0 } - let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - guard !tokens.isEmpty else { return 0 } + score( + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } + ) + } - let normalizedCandidates = candidates - .map(normalize) - .filter { !$0.isEmpty } + static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } guard !normalizedCandidates.isEmpty else { return nil } var totalScore = 0 - for token in tokens { + for token in preparedQuery.tokens { var bestTokenScore: Int? for candidate in normalizedCandidates { guard let candidateScore = scoreToken(token, in: candidate) else { continue } @@ -5484,19 +6157,19 @@ enum CommandPaletteFuzzyMatcher { } static func matchCharacterIndices(query: String, candidate: String) -> Set { - let normalizedQuery = normalize(query) - guard !normalizedQuery.isEmpty else { return [] } + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } - let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - guard !tokens.isEmpty else { return [] } + static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + guard !preparedQuery.isEmpty else { return [] } - let loweredCandidate = normalize(candidate) + let loweredCandidate = normalizeForSearch(candidate) guard !loweredCandidate.isEmpty else { return [] } let candidateChars = Array(loweredCandidate) var matched: Set = [] - for token in tokens { + for token in preparedQuery.tokens { if token == loweredCandidate { matched.formUnion(0.. String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - private static func scoreToken(_ token: String, in candidate: String) -> Int? { guard !token.isEmpty else { return 0 } @@ -5561,6 +6232,12 @@ enum CommandPaletteFuzzyMatcher { if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidateChars: candidateChars + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) + } if let range = candidate.range(of: token) { let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) @@ -5621,6 +6298,35 @@ enum CommandPaletteFuzzyMatcher { return best } + private static func singleEditWordPrefixScore( + tokenChars: [Character], + candidateChars: [Character] + ) -> Int? { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars + ) else { + return nil + } + return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + } + + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty + } + private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { guard !tokenChars.isEmpty else { return nil } let segments = wordSegments(candidateChars) @@ -5655,9 +6361,10 @@ enum CommandPaletteFuzzyMatcher { candidateChars: [Character], candidateStart: Int ) -> Bool { - guard length > 0 else { return false } + guard length >= 0 else { return false } guard tokenStart + length <= tokenChars.count else { return false } guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } for offset in 0.. SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleEditWordPrefixMatch? + var bestScore: Int? + + for segment in wordSegments(candidateChars) { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: (start: Int, end: Int) + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength + 1 >= tokenChars.count else { return nil } + + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 + } + + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) + } + + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil + } + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { var segments: [(start: Int, end: Int)] = [] var index = 0 @@ -5902,6 +6783,121 @@ enum CommandPaletteFuzzyMatcher { } } +struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let normalizedSearchableTexts: [String] + + init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { + self.payload = payload + self.rank = rank + self.title = title + self.normalizedSearchableTexts = searchableTexts + .map(CommandPaletteFuzzyMatcher.normalizeForSearch) + .filter { !$0.isEmpty } + } +} + +struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set +} + +enum CommandPaletteSearchEngine { + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: nil + ) + } + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: @escaping () -> Bool + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: Optional(shouldCancel) + ) + } + + private static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: (() -> Bool)? + ) -> [CommandPaletteSearchCorpusResult] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + var results: [CommandPaletteSearchCorpusResult] = [] + results.reserveCapacity(entries.count) + + func shouldCancelSearch(at index: Int) -> Bool { + guard let shouldCancel else { return false } + return index % 16 == 0 && shouldCancel() + } + + if queryIsEmpty { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: historyBoost(entry.payload, true), + titleMatchIndices: [] + ) + ) + } + } else { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { + continue + } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) + ) + ) + } + } + + if shouldCancel?() == true { return [] } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } +} + private struct SidebarResizerAccessibilityModifier: ViewModifier { let accessibilityIdentifier: String? @@ -7500,7 +8496,7 @@ private struct SidebarHelpMenuButton: View { helpPopover }) .accessibilityElement(children: .ignore) - .help(helpTitle) + .safeHelp(helpTitle) .accessibilityLabel(helpTitle) .accessibilityIdentifier("SidebarHelpMenuButton") } @@ -8273,7 +9269,7 @@ private struct TabItemView: View { .foregroundColor(activeSecondaryColor(0.7)) } .buttonStyle(.plain) - .help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip)) + .safeHelp(KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip)) .frame(width: 16, height: 16, alignment: .center) .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) @@ -8446,7 +9442,7 @@ private struct TabItemView: View { .foregroundColor(pullRequestForegroundColor) } .buttonStyle(.plain) - .help(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) + .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) } } } @@ -9313,7 +10309,7 @@ private struct SidebarMetadataRows: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .help(helpText) + .safeHelp(helpText) } private var activeSecondaryTextColor: Color { @@ -9353,7 +10349,7 @@ private struct SidebarMetadataEntryRow: View { rowContent(underlined: true) } .buttonStyle(.plain) - .help(url.absoluteString) + .safeHelp(url.absoluteString) } else { rowContent(underlined: false) .contentShape(Rectangle()) @@ -10147,7 +11143,7 @@ private struct DraggableFolderIcon: View { var body: some View { DraggableFolderIconRepresentable(directory: directory) .frame(width: 16, height: 16) - .help(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) + .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) .onTapGesture(count: 2) { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) } diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index 9a022e5f..b7f874ea 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -73,7 +73,7 @@ struct BrowserSearchOverlay: View { Image(systemName: "chevron.up") } .buttonStyle(SearchButtonStyle()) - .help("Next match (Return)") + .safeHelp("Next match (Return)") Button(action: { #if DEBUG @@ -84,7 +84,7 @@ struct BrowserSearchOverlay: View { Image(systemName: "chevron.down") } .buttonStyle(SearchButtonStyle()) - .help("Previous match (Shift+Return)") + .safeHelp("Previous match (Shift+Return)") Button(action: { #if DEBUG @@ -95,7 +95,7 @@ struct BrowserSearchOverlay: View { Image(systemName: "xmark") } .buttonStyle(SearchButtonStyle()) - .help("Close (Esc)") + .safeHelp("Close (Esc)") } .padding(8) .background(.background) diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index 0efc3d50..aee272e9 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -88,7 +88,7 @@ struct SurfaceSearchOverlay: View { Image(systemName: "chevron.up") } .buttonStyle(SearchButtonStyle()) - .help(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)")) + .safeHelp(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)")) Button(action: { #if DEBUG @@ -99,7 +99,7 @@ struct SurfaceSearchOverlay: View { Image(systemName: "chevron.down") } .buttonStyle(SearchButtonStyle()) - .help(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)")) + .safeHelp(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)")) Button(action: { #if DEBUG @@ -110,7 +110,7 @@ struct SurfaceSearchOverlay: View { Image(systemName: "xmark") } .buttonStyle(SearchButtonStyle()) - .help(String(localized: "search.close.help", defaultValue: "Close (Esc)")) + .safeHelp(String(localized: "search.close.help", defaultValue: "Close (Esc)")) } .padding(8) .background(.background) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7855f1fc..4e2139e4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2121,6 +2121,10 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceView.tabId = newTabId } + func isAttached(to view: GhosttyNSView) -> Bool { + attachedView === view && surface != nil + } + func portalBindingGeneration() -> UInt64 { portalLifecycleGeneration } @@ -2262,6 +2266,9 @@ final class TerminalSurface: Identifiable, ObservableObject { // removed/re-added (or briefly have window/screen nil) without recreating the surface. // Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing // or stale, the surface can appear visually frozen until a focus/visibility change. + // SwiftUI also re-enters this path for ordinary state propagation (drag hover, active + // markers, visibility flags), so avoid forcing a geometry refresh when the attachment + // itself is unchanged. if attachedView === view && surface != nil { #if DEBUG dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") @@ -2272,7 +2279,6 @@ final class TerminalSurface: Identifiable, ObservableObject { let s = surface { ghostty_surface_set_display_id(s, displayID) } - view.forceRefreshSurface() return } @@ -2570,6 +2576,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } + @discardableResult func updateSize( width: CGFloat, height: CGFloat, @@ -2577,15 +2584,15 @@ final class TerminalSurface: Identifiable, ObservableObject { yScale: CGFloat, layerScale: CGFloat, backingSize: CGSize? = nil - ) { - guard let surface = surface else { return } + ) -> Bool { + guard let surface = surface else { return false } _ = layerScale let resolvedBackingWidth = backingSize?.width ?? (width * xScale) let resolvedBackingHeight = backingSize?.height ?? (height * yScale) let wpx = pixelDimension(from: resolvedBackingWidth) let hpx = pixelDimension(from: resolvedBackingHeight) - guard wpx > 0, hpx > 0 else { return } + guard wpx > 0, hpx > 0 else { return false } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight @@ -2594,7 +2601,7 @@ final class TerminalSurface: Identifiable, ObservableObject { Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)") #endif - guard scaleChanged || sizeChanged else { return } + guard scaleChanged || sizeChanged else { return false } #if DEBUG if sizeChanged { @@ -2616,10 +2623,11 @@ final class TerminalSurface: Identifiable, ObservableObject { } // Let Ghostty continue rendering on its own wakeups for steady-state frames. + return true } /// Force a full size recalculation and surface redraw. - func forceRefresh() { + func forceRefresh(reason: String = "unspecified") { let hasSurface = surface != nil let viewState: String if let view = attachedView { @@ -2632,7 +2640,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } #if DEBUG let ts = ISO8601DateFormatter().string(from: Date()) - let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n" + let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n" let logPath = "/tmp/cmux-refresh-debug.log" if let handle = FileHandle(forWritingAtPath: logPath) { handle.seekToEndOfFile() @@ -2941,6 +2949,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var lastScrollEventTime: CFTimeInterval = 0 private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? + private var lastDrawableSize: CGSize = .zero private var isFindEscapeSuppressionArmed = false #if DEBUG private var lastSizeSkipSignature: String? @@ -3114,14 +3123,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } func attachSurface(_ surface: TerminalSurface) { - appliedColorScheme = nil + let isSameSurface = terminalSurface === surface + let isAlreadyAttached = surface.isAttached(to: self) + if !isSameSurface { + appliedColorScheme = nil + } terminalSurface = surface tabId = surface.tabId - surface.attachToView(self) + if !isAlreadyAttached { + surface.attachToView(self) + } surface.setKeyboardCopyModeActive(keyboardCopyModeActive) - updateSurfaceSize() + if !isAlreadyAttached { + updateSurfaceSize() + } applySurfaceBackground() - applySurfaceColorScheme(force: true) + applySurfaceColorScheme(force: !isSameSurface || !isAlreadyAttached) } override func viewDidMoveToWindow() { @@ -3229,8 +3246,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return currentBounds } - private func updateSurfaceSize(size: CGSize? = nil) { - guard let terminalSurface = terminalSurface else { return } + @discardableResult + private func updateSurfaceSize(size: CGSize? = nil) -> Bool { + guard let terminalSurface = terminalSurface else { return false } let size = resolvedSurfaceSize(preferred: size) guard size.width > 0 && size.height > 0 else { #if DEBUG @@ -3244,7 +3262,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } pendingSurfaceSize = size guard let window else { @@ -3258,7 +3276,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } // First principles: derive pixel size from AppKit's backing conversion for the current @@ -3276,7 +3294,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastSizeSkipSignature = signature } #endif - return + return false } #if DEBUG if lastSizeSkipSignature != nil { @@ -3295,17 +3313,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { width: floor(max(0, backingSize.width)), height: floor(max(0, backingSize.height)) ) + var didChange = false CATransaction.begin() CATransaction.setDisableActions(true) + if let layer, !nearlyEqual(layer.contentsScale, layerScale) { + didChange = true + } layer?.contentsScale = layerScale layer?.masksToBounds = true if let metalLayer = layer as? CAMetalLayer { - metalLayer.drawableSize = drawablePixelSize + if drawablePixelSize != lastDrawableSize || metalLayer.drawableSize != drawablePixelSize { + if metalLayer.drawableSize != drawablePixelSize { + didChange = true + } + if metalLayer.drawableSize != drawablePixelSize { + metalLayer.drawableSize = drawablePixelSize + } + lastDrawableSize = drawablePixelSize + } } CATransaction.commit() - terminalSurface.updateSize( + let surfaceSizeChanged = terminalSurface.updateSize( width: size.width, height: size.height, xScale: xScale, @@ -3313,15 +3343,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { layerScale: layerScale, backingSize: backingSize ) + return didChange || surfaceSizeChanged } - fileprivate func pushTargetSurfaceSize(_ size: CGSize) { + @discardableResult + fileprivate func pushTargetSurfaceSize(_ size: CGSize) -> Bool { updateSurfaceSize(size: size) } - /// Force a full size recalculation and Metal layer refresh. - /// Resets cached metrics so updateSurfaceSize() re-runs unconditionally. - func forceRefreshSurface() { + /// Force a full size reconciliation for the current bounds. + /// Keep the drawable-size cache intact so redundant refresh paths do not + /// reallocate Metal drawables when the pixel size is unchanged. + @discardableResult + func forceRefreshSurface() -> Bool { updateSurfaceSize() } @@ -4654,6 +4688,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) } + if let trackingArea { + removeTrackingArea(trackingArea) + } terminalSurface = nil } @@ -4882,6 +4919,7 @@ final class GhosttySurfaceScrollView: NSView { private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeLabel: NSTextField private var searchOverlayHostingView: NSHostingView? + private var lastSearchOverlayStateID: ObjectIdentifier? private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false @@ -4908,6 +4946,9 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG private var lastDropZoneOverlayLogSignature: String? + private var dragLayoutLogSequence: UInt64 = 0 + private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder") private static var flashCounts: [UUID: Int] = [:] private static var drawCounts: [UUID: Int] = [:] private static var lastDrawTimes: [UUID: CFTimeInterval] = [:] @@ -5238,36 +5279,50 @@ final class GhosttySurfaceScrollView: NSView { /// Reconcile AppKit geometry with ghostty surface geometry synchronously. /// Used after split topology mutations (close/split) to prevent a stale one-frame /// IOSurface size from being presented after pane expansion. - func reconcileGeometryNow() { + @discardableResult + func reconcileGeometryNow() -> Bool { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.reconcileGeometryNow() } - return + return false } - synchronizeGeometryAndContent() + return synchronizeGeometryAndContent() } /// Request an immediate terminal redraw after geometry updates so stale IOSurface /// contents do not remain stretched during live resize churn. - func refreshSurfaceNow() { - surfaceView.terminalSurface?.forceRefresh() + func refreshSurfaceNow(reason: String = "portal.refreshSurfaceNow") { + surfaceView.terminalSurface?.forceRefresh(reason: reason) } - private func synchronizeGeometryAndContent() { + @discardableResult + private func synchronizeGeometryAndContent() -> Bool { CATransaction.begin() CATransaction.setDisableActions(true) defer { CATransaction.commit() } - backgroundView.frame = bounds - scrollView.frame = bounds + let previousSurfaceSize = surfaceView.frame.size + _ = setFrameIfNeeded(backgroundView, to: bounds) + _ = setFrameIfNeeded(scrollView, to: bounds) let targetSize = scrollView.bounds.size - surfaceView.frame.size = targetSize - documentView.frame.size.width = scrollView.bounds.width - inactiveOverlayView.frame = bounds +#if DEBUG + logLayoutDuringActiveDrag(targetSize: targetSize) +#endif + let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize) + _ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame) + let targetDocumentFrame = CGRect( + origin: documentView.frame.origin, + size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height) + ) + _ = setFrameIfNeeded(documentView, to: targetDocumentFrame) + _ = setFrameIfNeeded(inactiveOverlayView, to: bounds) if let zone = activeDropZone { - dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size) + _ = setFrameIfNeeded( + dropZoneOverlayView, + to: dropZoneOverlayFrame(for: zone, in: bounds.size) + ) } if let pending = pendingDropZone, bounds.width > 2, @@ -5281,15 +5336,68 @@ final class GhosttySurfaceScrollView: NSView { // same initial animation as direct drop-zone activation. setDropZoneOverlay(zone: pending) } - notificationRingOverlayView.frame = bounds - flashOverlayView.frame = bounds + _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) + _ = setFrameIfNeeded(flashOverlayView, to: bounds) updateNotificationRingPath() updateFlashPath() synchronizeScrollView() synchronizeSurfaceView() - synchronizeCoreSurface() + let didCoreSurfaceChange = synchronizeCoreSurface() + return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange } + @discardableResult + private func setFrameIfNeeded(_ view: NSView, to frame: CGRect) -> Bool { + guard !Self.rectApproximatelyEqual(view.frame, frame) else { return false } + view.frame = frame + return true + } + + private func sizeApproximatelyEqual(_ lhs: CGSize, _ rhs: CGSize, epsilon: CGFloat = 0.0001) -> Bool { + abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon + } + + private func pointApproximatelyEqual(_ lhs: CGPoint, _ rhs: CGPoint, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.x - rhs.x) <= epsilon && abs(lhs.y - rhs.y) <= epsilon + } + +#if DEBUG + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } + + private func logLayoutDuringActiveDrag(targetSize: CGSize) { + let pasteboardTypes = NSPasteboard(name: .drag).types + let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true + let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true + let eventType = NSApp.currentEvent?.type + let hasActiveDrag = + activeDropZone != nil || + pendingDropZone != nil || + ((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType)) + guard hasActiveDrag else { return } + + dragLayoutLogSequence &+= 1 + let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let activeZone = activeDropZone.map { String(describing: $0) } ?? "none" + let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none" + let event = eventType.map { String(describing: $0) } ?? "nil" + dlog( + "terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " + + "activeZone=\(activeZone) pendingZone=\(pendingZone) " + + "hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " + + "event=\(event) inWindow=\(window != nil ? 1 : 0) " + + "bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "target=\(String(format: "%.1fx%.1f", targetSize.width, targetSize.height))" + ) + } +#endif + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() windowObservers.forEach { NotificationCenter.default.removeObserver($0) } @@ -5385,10 +5493,15 @@ final class GhosttySurfaceScrollView: NSView { return } + let targetHidden = !visible + let targetOpacity: Float = visible ? 1 : 0 + guard notificationRingOverlayView.isHidden != targetHidden || + notificationRingLayer.opacity != targetOpacity else { return } + CATransaction.begin() CATransaction.setDisableActions(true) - notificationRingOverlayView.isHidden = !visible - notificationRingLayer.opacity = visible ? 1 : 0 + notificationRingOverlayView.isHidden = targetHidden + notificationRingLayer.opacity = targetOpacity CATransaction.commit() } @@ -5405,6 +5518,8 @@ final class GhosttySurfaceScrollView: NSView { guard let terminalSurface = surfaceView.terminalSurface, let searchState else { let hadOverlay = searchOverlayHostingView != nil + lastSearchOverlayStateID = nil + guard hadOverlay else { return } #if DEBUG dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)") #endif @@ -5414,6 +5529,16 @@ final class GhosttySurfaceScrollView: NSView { return } + let searchStateID = ObjectIdentifier(searchState) + if let overlay = searchOverlayHostingView, + lastSearchOverlayStateID == searchStateID, + overlay.superview === self { + if !keyboardCopyModeBadgeView.isHidden { + addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) + } + return + } + let hadOverlay = searchOverlayHostingView != nil #if DEBUG dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")") @@ -5457,6 +5582,7 @@ final class GhosttySurfaceScrollView: NSView { if !keyboardCopyModeBadgeView.isHidden { addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) } + lastSearchOverlayStateID = searchStateID return } @@ -5474,6 +5600,7 @@ final class GhosttySurfaceScrollView: NSView { addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) } searchOverlayHostingView = overlay + lastSearchOverlayStateID = searchStateID } func setKeyboardCopyModeIndicator(visible: Bool) { @@ -6356,16 +6483,18 @@ final class GhosttySurfaceScrollView: NSView { private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect + guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return } surfaceView.frame.origin = visibleRect.origin } /// Match upstream Ghostty behavior: use content area width (excluding non-content /// regions such as scrollbar space) when telling libghostty the terminal size. - private func synchronizeCoreSurface() { + @discardableResult + private func synchronizeCoreSurface() -> Bool { let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) let height = surfaceView.frame.height - guard width > 0, height > 0 else { return } - surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) + guard width > 0, height > 0 else { return false } + return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } /// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller. @@ -6425,19 +6554,30 @@ final class GhosttySurfaceScrollView: NSView { } private func synchronizeScrollView() { - documentView.frame.size.height = documentHeight() + var didChangeGeometry = false + let targetDocumentHeight = documentHeight() + if abs(documentView.frame.height - targetDocumentHeight) > 0.5 { + documentView.frame.size.height = targetDocumentHeight + didChangeGeometry = true + } if !isLiveScrolling { let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight - scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + let targetOrigin = CGPoint(x: 0, y: offsetY) + if !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) { + scrollView.contentView.scroll(to: targetOrigin) + didChangeGeometry = true + } lastSentRow = Int(scrollbar.offset) } } - scrollView.reflectScrolledClipView(scrollView.contentView) + if didChangeGeometry { + scrollView.reflectScrolledClipView(scrollView.contentView) + } } private func handleScrollChange() { @@ -6669,31 +6809,57 @@ struct GhosttyTerminalView: NSViewRepresentable { private final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? + + private struct GeometryState: Equatable { + let frame: CGRect + let bounds: CGRect + let windowNumber: Int? + let superviewID: ObjectIdentifier? + } + + private func currentGeometryState() -> GeometryState { + GeometryState( + frame: frame, + bounds: bounds, + windowNumber: window?.windowNumber, + superviewID: superview.map(ObjectIdentifier.init) + ) + } + + private func notifyGeometryChangedIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + lastReportedGeometryState = state + geometryRevision &+= 1 + onGeometryChanged?() + } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() onDidMoveToWindow?() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func layout() { super.layout() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - onGeometryChanged?() + notifyGeometryChangedIfNeeded() } } @@ -6706,6 +6872,7 @@ struct GhosttyTerminalView: NSViewRepresentable { var desiredPortalZPriority: Int = 0 var lastBoundHostId: ObjectIdentifier? var lastPaneDropZone: DropZone? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 weak var hostedView: GhosttySurfaceScrollView? } @@ -6825,6 +6992,7 @@ struct GhosttyTerminalView: NSViewRepresentable { expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = ObjectIdentifier(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) @@ -6856,17 +7024,30 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } TerminalWindowPortalRegistry.synchronizeForAnchor(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } if host.window != nil { let hostId = ObjectIdentifier(host) + let geometryRevision = host.geometryRevision + let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) let shouldBindNow = coordinator.lastBoundHostId != hostId || hostedView.superview == nil || + portalEntryMissing || previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority if shouldBindNow { +#if DEBUG + if portalEntryMissing { + dlog( + "ws.hostState.rebindOnUpdate surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" + ) + } +#endif TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, @@ -6876,8 +7057,11 @@ struct GhosttyTerminalView: NSViewRepresentable { expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + TerminalWindowPortalRegistry.synchronizeForAnchor(host) + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - TerminalWindowPortalRegistry.synchronizeForAnchor(host) } else { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 370477f0..096dce20 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -1,3 +1,4 @@ +import Bonsplit import SwiftUI struct NotificationsPage: View { @@ -113,7 +114,7 @@ struct NotificationsPage: View { } .buttonStyle(.bordered) .keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers) - .help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))) + .safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))) .disabled(!hasUnreadNotifications) } else { Button(action: { @@ -125,7 +126,7 @@ struct NotificationsPage: View { } } .buttonStyle(.bordered) - .help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))) + .safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))) .disabled(!hasUnreadNotifications) } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index fc803000..9a8d76c2 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1412,7 +1412,230 @@ final class BrowserPanel: Panel, ObservableObject { /// Used to keep omnibar text-field focus from being immediately stolen by panel focus. private var suppressWebViewFocusUntil: Date? private var suppressWebViewFocusForAddressBar: Bool = false + private var addressBarFocusRestoreGeneration: UInt64 = 0 private let blankURLString = "about:blank" + private static let addressBarFocusCaptureScript = """ + (() => { + try { + const syncState = (state) => { + window.__cmuxAddressBarFocusState = state; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = state; + } + } catch (_) {} + }; + + const active = document.activeElement; + if (!active) { + syncState(null); + return "cleared:none"; + } + + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const isEditable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + if (!isEditable) { + syncState(null); + return "cleared:noneditable"; + } + + let id = active.getAttribute("data-cmux-addressbar-focus-id"); + if (!id) { + id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); + active.setAttribute("data-cmux-addressbar-focus-id", id); + } + + const state = { id, selectionStart: null, selectionEnd: null }; + if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") { + state.selectionStart = active.selectionStart; + state.selectionEnd = active.selectionEnd; + } + syncState(state); + return "captured:" + id; + } catch (_) { + return "error"; + } + })(); + """ + private static let addressBarFocusTrackingBootstrapScript = """ + (() => { + try { + if (window.__cmuxAddressBarFocusTrackerInstalled) return true; + window.__cmuxAddressBarFocusTrackerInstalled = true; + + const syncState = (state) => { + window.__cmuxAddressBarFocusState = state; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: state }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = state; + } + } catch (_) {} + }; + + if (window.top === window && !window.__cmuxAddressBarFocusMessageBridgeInstalled) { + window.__cmuxAddressBarFocusMessageBridgeInstalled = true; + window.addEventListener("message", (ev) => { + try { + const data = ev ? ev.data : null; + if (!data || !Object.prototype.hasOwnProperty.call(data, "cmuxAddressBarFocusState")) return; + window.__cmuxAddressBarFocusState = data.cmuxAddressBarFocusState || null; + } catch (_) {} + }, true); + } + + const isEditable = (el) => { + if (!el) return false; + const tag = (el.tagName || "").toLowerCase(); + const type = (el.type || "").toLowerCase(); + return !!el.isContentEditable || tag === "textarea" || (tag === "input" && type !== "hidden"); + }; + + const ensureFocusId = (el) => { + let id = el.getAttribute("data-cmux-addressbar-focus-id"); + if (!id) { + id = "cmux-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); + el.setAttribute("data-cmux-addressbar-focus-id", id); + } + return id; + }; + + const snapshot = (el) => { + if (!isEditable(el)) { + syncState(null); + return; + } + const state = { + id: ensureFocusId(el), + selectionStart: null, + selectionEnd: null + }; + if (typeof el.selectionStart === "number" && typeof el.selectionEnd === "number") { + state.selectionStart = el.selectionStart; + state.selectionEnd = el.selectionEnd; + } + syncState(state); + }; + + document.addEventListener("focusin", (ev) => { + snapshot(ev && ev.target ? ev.target : document.activeElement); + }, true); + document.addEventListener("selectionchange", () => { + snapshot(document.activeElement); + }, true); + document.addEventListener("input", () => { + snapshot(document.activeElement); + }, true); + document.addEventListener("mousedown", (ev) => { + const target = ev && ev.target ? ev.target : null; + if (!isEditable(target)) { + syncState(null); + } + }, true); + window.addEventListener("beforeunload", () => { + syncState(null); + }, true); + + snapshot(document.activeElement); + return true; + } catch (_) { + return false; + } + })(); + """ + private static let addressBarFocusRestoreScript = """ + (() => { + try { + const readState = () => { + let state = window.__cmuxAddressBarFocusState; + try { + if ((!state || typeof state.id !== "string" || !state.id) && + window.top && window.top.__cmuxAddressBarFocusState) { + state = window.top.__cmuxAddressBarFocusState; + } + } catch (_) {} + return state; + }; + + const clearState = () => { + window.__cmuxAddressBarFocusState = null; + try { + if (window.top && window.top !== window) { + window.top.postMessage({ cmuxAddressBarFocusState: null }, "*"); + } else if (window.top) { + window.top.__cmuxAddressBarFocusState = null; + } + } catch (_) {} + }; + + const state = readState(); + if (!state || typeof state.id !== "string" || !state.id) { + return "no_state"; + } + + const selector = '[data-cmux-addressbar-focus-id="' + state.id + '"]'; + const findTarget = (doc) => { + if (!doc) return null; + const direct = doc.querySelector(selector); + if (direct && direct.isConnected) return direct; + const frames = doc.querySelectorAll("iframe,frame"); + for (let i = 0; i < frames.length; i += 1) { + const frame = frames[i]; + try { + const childDoc = frame.contentDocument; + if (!childDoc) continue; + const nested = findTarget(childDoc); + if (nested) return nested; + } catch (_) {} + } + return null; + }; + + const target = findTarget(document); + if (!target) { + clearState(); + return "missing_target"; + } + + try { + target.focus({ preventScroll: true }); + } catch (_) { + try { target.focus(); } catch (_) {} + } + + let focused = false; + try { + focused = + target === target.ownerDocument.activeElement || + (typeof target.matches === "function" && target.matches(":focus")); + } catch (_) {} + if (!focused) { + return "not_focused"; + } + + if ( + typeof state.selectionStart === "number" && + typeof state.selectionEnd === "number" && + typeof target.setSelectionRange === "function" + ) { + try { + target.setSelectionRange(state.selectionStart, state.selectionEnd); + } catch (_) {} + } + clearState(); + return "restored"; + } catch (_) { + return "error"; + } + })(); + """ /// Published URL being displayed @Published private(set) var currentURL: URL? @@ -1561,6 +1784,15 @@ final class BrowserPanel: Panel, ObservableObject { forMainFrameOnly: false ) ) + // Track the last editable focused element continuously so omnibar exit can + // restore page input focus even if capture runs after first-responder handoff. + config.userContentController.addUserScript( + WKUserScript( + source: Self.addressBarFocusTrackingBootstrapScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) let webView = CmuxWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true @@ -2750,14 +2982,29 @@ extension BrowserPanel { func suppressOmnibarAutofocus(for seconds: TimeInterval) { suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds) +#if DEBUG + dlog( + "browser.focus.omnibarAutofocus.suppress panel=\(id.uuidString.prefix(5)) " + + "seconds=\(String(format: "%.2f", seconds))" + ) +#endif } func suppressWebViewFocus(for seconds: TimeInterval) { suppressWebViewFocusUntil = Date().addingTimeInterval(seconds) +#if DEBUG + dlog( + "browser.focus.webView.suppress panel=\(id.uuidString.prefix(5)) " + + "seconds=\(String(format: "%.2f", seconds))" + ) +#endif } func clearWebViewFocusSuppression() { suppressWebViewFocusUntil = nil +#if DEBUG + dlog("browser.focus.webView.suppress.clear panel=\(id.uuidString.prefix(5))") +#endif } func shouldSuppressOmnibarAutofocus() -> Bool { @@ -2781,12 +3028,17 @@ extension BrowserPanel { } func beginSuppressWebViewFocusForAddressBar() { - if !suppressWebViewFocusForAddressBar { + let enteringAddressBar = !suppressWebViewFocusForAddressBar + if enteringAddressBar { #if DEBUG dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") #endif + invalidateAddressBarPageFocusRestoreAttempts() } suppressWebViewFocusForAddressBar = true + if enteringAddressBar { + captureAddressBarPageFocusIfNeeded() + } } func endSuppressWebViewFocusForAddressBar() { @@ -2802,16 +3054,175 @@ extension BrowserPanel { func requestAddressBarFocus() -> UUID { beginSuppressWebViewFocusForAddressBar() if let pendingAddressBarFocusRequestId { +#if DEBUG + dlog( + "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + + "request=\(pendingAddressBarFocusRequestId.uuidString.prefix(8)) result=reuse_pending" + ) +#endif return pendingAddressBarFocusRequestId } let requestId = UUID() pendingAddressBarFocusRequestId = requestId +#if DEBUG + dlog( + "browser.focus.addressBar.request panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=new" + ) +#endif return requestId } func acknowledgeAddressBarFocusRequest(_ requestId: UUID) { - guard pendingAddressBarFocusRequestId == requestId else { return } + guard pendingAddressBarFocusRequestId == requestId else { +#if DEBUG + dlog( + "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=ignored " + + "pending=\(pendingAddressBarFocusRequestId?.uuidString.prefix(8) ?? "nil")" + ) +#endif + return + } pendingAddressBarFocusRequestId = nil +#if DEBUG + dlog( + "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) result=cleared" + ) +#endif + } + + private func captureAddressBarPageFocusIfNeeded() { + webView.evaluateJavaScript(Self.addressBarFocusCaptureScript) { [weak self] result, error in +#if DEBUG + guard let self else { return } + if let error { + dlog( + "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + + "result=error message=\(error.localizedDescription)" + ) + return + } + let resultValue = (result as? String) ?? "unknown" + dlog( + "browser.focus.addressBar.capture panel=\(self.id.uuidString.prefix(5)) " + + "result=\(resultValue)" + ) +#else + _ = self + _ = result + _ = error +#endif + } + } + + private enum AddressBarPageFocusRestoreStatus: String { + case restored + case noState = "no_state" + case missingTarget = "missing_target" + case notFocused = "not_focused" + case error + } + + private static func addressBarPageFocusRestoreStatus( + from result: Any?, + error: Error? + ) -> AddressBarPageFocusRestoreStatus { + if error != nil { return .error } + guard let raw = result as? String else { return .error } + return AddressBarPageFocusRestoreStatus(rawValue: raw) ?? .error + } + + func invalidateAddressBarPageFocusRestoreAttempts() { + addressBarFocusRestoreGeneration &+= 1 +#if DEBUG + dlog( + "browser.focus.addressBar.restore.invalidate panel=\(id.uuidString.prefix(5)) " + + "generation=\(addressBarFocusRestoreGeneration)" + ) +#endif + } + + func restoreAddressBarPageFocusIfNeeded(completion: @escaping (Bool) -> Void) { + addressBarFocusRestoreGeneration &+= 1 + let generation = addressBarFocusRestoreGeneration + let delays: [TimeInterval] = [0.0, 0.03, 0.09, 0.2] + restoreAddressBarPageFocusAttemptIfNeeded( + attempt: 0, + delays: delays, + generation: generation, + completion: completion + ) + } + + private func restoreAddressBarPageFocusAttemptIfNeeded( + attempt: Int, + delays: [TimeInterval], + generation: UInt64, + completion: @escaping (Bool) -> Void + ) { + guard generation == addressBarFocusRestoreGeneration else { + completion(false) + return + } + webView.evaluateJavaScript(Self.addressBarFocusRestoreScript) { [weak self] result, error in + guard let self else { + completion(false) + return + } + guard generation == self.addressBarFocusRestoreGeneration else { + completion(false) + return + } + + let status = Self.addressBarPageFocusRestoreStatus(from: result, error: error) + let canRetry = (status == .notFocused || status == .error) + let hasNextAttempt = attempt + 1 < delays.count + +#if DEBUG + if let error { + dlog( + "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + + "attempt=\(attempt) status=\(status.rawValue) " + + "message=\(error.localizedDescription)" + ) + } else { + dlog( + "browser.focus.addressBar.restore panel=\(self.id.uuidString.prefix(5)) " + + "attempt=\(attempt) status=\(status.rawValue)" + ) + } +#endif + + if status == .restored { + completion(true) + return + } + + if canRetry && hasNextAttempt { + let delay = delays[attempt + 1] + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { + completion(false) + return + } + guard generation == self.addressBarFocusRestoreGeneration else { + completion(false) + return + } + self.restoreAddressBarPageFocusAttemptIfNeeded( + attempt: attempt + 1, + delays: delays, + generation: generation, + completion: completion + ) + } + return + } + + completion(false) + } } /// Returns the most reliable URL string for omnibar-related matching and UI decisions. diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 198d42c0..c4b99477 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -232,6 +232,8 @@ struct BrowserPanelView: View { @State private var omnibarPillFrame: CGRect = .zero @State private var addressBarHeight: CGFloat = 0 @State private var lastHandledAddressBarFocusRequestId: UUID? + @State private var pendingAddressBarFocusRetryRequestId: UUID? + @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 @State private var isBrowserThemeMenuPresented = false @State private var ghosttyBackgroundGeneration: Int = 0 // Keep this below half of the compact omnibar height so it reads as a squircle, @@ -379,7 +381,15 @@ struct BrowserPanelView: View { "addressFocused=\(addressBarFocused ? 1 : 0)" ) #endif - onRequestPanelFocus() + if addressBarFocused { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.webViewClickBlur") +#endif + setAddressBarFocused(false, reason: "webView.clickIntent") + } + if !isFocused { + onRequestPanelFocus() + } } .onAppear { UserDefaults.standard.register(defaults: [ @@ -399,6 +409,9 @@ struct BrowserPanelView: View { autoFocusOmnibarIfBlank() syncWebViewResponderPolicyWithViewState(reason: "onAppear") BrowserHistoryStore.shared.loadIfNeeded() +#if DEBUG + logBrowserFocusState(event: "view.onAppear") +#endif } .onChange(of: panel.focusFlashToken) { _ in triggerFocusFlashAnimation() @@ -412,7 +425,7 @@ struct BrowserPanelView: View { !panel.shouldSuppressWebViewFocus(), addressWasEmpty, !isWebViewBlank() { - addressBarFocused = false + setAddressBarFocused(false, reason: "panel.currentURL.loaded") } } .onChange(of: browserThemeModeRaw) { _ in @@ -429,17 +442,30 @@ struct BrowserPanelView: View { applyPendingAddressBarFocusRequestIfNeeded() } .onChange(of: isFocused) { focused in +#if DEBUG + logBrowserFocusState( + event: "panelFocus.onChange", + detail: "next=\(focused ? 1 : 0)" + ) +#endif // Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive). if focused { applyPendingAddressBarFocusRequestIfNeeded() autoFocusOmnibarIfBlank() } else { + panel.invalidateAddressBarPageFocusRestoreAttempts() hideSuggestions() - addressBarFocused = false + setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused") } syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged") } .onChange(of: addressBarFocused) { focused in +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.onChange", + detail: "next=\(focused ? 1 : 0)" + ) +#endif let urlString = panel.preferredURLStringForOmnibar() ?? "" if focused { panel.beginSuppressWebViewFocusForAddressBar() @@ -447,6 +473,9 @@ struct BrowserPanelView: View { // Only request panel focus if this pane isn't currently focused. When already // focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit. if !isFocused { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.requestPanelFocus") +#endif onRequestPanelFocus() } let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) @@ -466,11 +495,17 @@ struct BrowserPanelView: View { inlineCompletion = nil } syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged") +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.onChange.applied") +#endif } .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in guard let panelId = notification.object as? UUID, panelId == panel.id else { return } guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.moveSelection", detail: "delta=\(delta)") +#endif let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta)) applyOmnibarEffects(effects) refreshInlineCompletion() @@ -484,7 +519,10 @@ struct BrowserPanelView: View { return panelId == panel.id }) { _ in if addressBarFocused { - addressBarFocused = false +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.externalBlur") +#endif + setAddressBarFocused(false, reason: "notification.externalBlur") } } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in @@ -538,7 +576,7 @@ struct BrowserPanelView: View { .buttonStyle(OmnibarAddressButtonStyle()) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) - .help(String(localized: "browser.goBack", defaultValue: "Go Back")) + .safeHelp(String(localized: "browser.goBack", defaultValue: "Go Back")) Button(action: { #if DEBUG @@ -554,7 +592,7 @@ struct BrowserPanelView: View { .buttonStyle(OmnibarAddressButtonStyle()) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) - .help(String(localized: "browser.goForward", defaultValue: "Go Forward")) + .safeHelp(String(localized: "browser.goForward", defaultValue: "Go Forward")) Button(action: { if panel.isLoading { @@ -575,7 +613,7 @@ struct BrowserPanelView: View { .contentShape(Rectangle()) } .buttonStyle(OmnibarAddressButtonStyle()) - .help(panel.isLoading ? String(localized: "browser.stop", defaultValue: "Stop") : String(localized: "browser.reload", defaultValue: "Reload")) + .safeHelp(panel.isLoading ? String(localized: "browser.stop", defaultValue: "Stop") : String(localized: "browser.reload", defaultValue: "Reload")) if panel.isDownloading { HStack(spacing: 4) { @@ -586,7 +624,7 @@ struct BrowserPanelView: View { .foregroundStyle(.secondary) } .padding(.leading, 6) - .help(String(localized: "browser.downloadInProgress", defaultValue: "Download in progress")) + .safeHelp(String(localized: "browser.downloadInProgress", defaultValue: "Download in progress")) } } } @@ -604,7 +642,7 @@ struct BrowserPanelView: View { } .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .help(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools"))) + .safeHelp(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools"))) .accessibilityIdentifier("BrowserToggleDevToolsButton") } @@ -624,7 +662,7 @@ struct BrowserPanelView: View { .popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) { browserThemeModePopover } - .help("Browser Theme: \(browserThemeMode.displayName)") + .safeHelp("Browser Theme: \(browserThemeMode.displayName)") .accessibilityIdentifier("BrowserThemeModeButton") } @@ -696,14 +734,14 @@ struct BrowserPanelView: View { panel.navigateSmart(omnibarState.buffer) hideSuggestions() suppressNextFocusLostRevert = true - addressBarFocused = false + setAddressBarFocused(false, reason: "omnibar.submit.navigate") } }, onEscape: { handleOmnibarEscape() }, onFieldLostFocus: { - addressBarFocused = false + setAddressBarFocused(false, reason: "omnibar.fieldLostFocus") }, onMoveSelection: { delta in guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return } @@ -773,6 +811,7 @@ struct BrowserPanelView: View { }, paneTopChromeHeight: addressBarHeight ) + .accessibilityIdentifier("BrowserWebViewSurface") // Keep the host stable for normal pane churn, but force a remount when // BrowserPanel replaces its underlying WKWebView after process termination. .id(panel.webViewInstanceID) @@ -782,7 +821,10 @@ struct BrowserPanelView: View { // Chrome-like behavior: clicking web content while editing the // omnibar should commit blur and revert transient edits. if addressBarFocused { - addressBarFocused = false +#if DEBUG + logBrowserFocusState(event: "webContent.tapBlur") +#endif + setAddressBarFocused(false, reason: "webContent.tapBlur") } }) } else { @@ -792,7 +834,7 @@ struct BrowserPanelView: View { .onTapGesture { onRequestPanelFocus() if addressBarFocused { - addressBarFocused = false + setAddressBarFocused(false, reason: "placeholderContent.tapBlur") } } } @@ -839,6 +881,82 @@ struct BrowserPanelView: View { cmuxWebView.allowsFirstResponderAcquisition = next } + private func setAddressBarFocused(_ focused: Bool, reason: String) { +#if DEBUG + if addressBarFocused == focused { + logBrowserFocusState( + event: "addressBarFocus.write.noop", + detail: "reason=\(reason) value=\(focused ? 1 : 0)" + ) + } else { + logBrowserFocusState( + event: "addressBarFocus.write", + detail: "reason=\(reason) old=\(addressBarFocused ? 1 : 0) new=\(focused ? 1 : 0)" + ) + } +#endif + addressBarFocused = focused + } + + private func browserFocusResponderChainContains( + _ start: NSResponder?, + target: NSResponder + ) -> Bool { + var current = start + var hops = 0 + while let responder = current, hops < 64 { + if responder === target { return true } + current = responder.nextResponder + hops += 1 + } + return false + } + + private func isPanelFocusedInModel() -> Bool { + guard let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: panel.workspaceId), + manager.selectedTabId == panel.workspaceId, + let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }) else { + return false + } + return workspace.focusedPanelId == panel.id + } + + private func shouldApplyAddressBarExitFallback(in window: NSWindow) -> Bool { + panel.webView.window === window && isPanelFocusedInModel() + } + +#if DEBUG + private func browserFocusWindow() -> NSWindow? { + panel.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } + + private func browserFocusResponderDescription(_ responder: NSResponder?) -> String { + guard let responder else { return "nil" } + return String(describing: type(of: responder)) + } + + private func logBrowserFocusState(event: String, detail: String = "") { + let window = browserFocusWindow() + let firstResponder = window?.firstResponder + let firstResponderType = browserFocusResponderDescription(firstResponder) + let webResponder = browserFocusResponderChainContains(firstResponder, target: panel.webView) ? 1 : 0 + var line = + "browser.focus.trace event=\(event) panel=\(panel.id.uuidString.prefix(5)) " + + "panelFocused=\(isFocused ? 1 : 0) addrFocused=\(addressBarFocused ? 1 : 0) " + + "suppressWeb=\(panel.shouldSuppressWebViewFocus() ? 1 : 0) " + + "suppressAuto=\(panel.shouldSuppressOmnibarAutofocus() ? 1 : 0) " + + "webResponder=\(webResponder) win=\(window?.windowNumber ?? -1) fr=\(firstResponderType)" + if let pending = panel.pendingAddressBarFocusRequestId { + line += " pending=\(pending.uuidString.prefix(8))" + } + if !detail.isEmpty { + line += " \(detail)" + } + dlog(line) + } +#endif + private func syncURLFromPanel() { let urlString = panel.preferredURLStringForOmnibar() ?? "" let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString)) @@ -868,12 +986,57 @@ struct BrowserPanelView: View { return false } + private func clearPendingAddressBarFocusRetry() { + pendingAddressBarFocusRetryRequestId = nil + pendingAddressBarFocusRetryGeneration &+= 1 + } + + private func schedulePendingAddressBarFocusRetryIfNeeded(requestId: UUID) { + guard pendingAddressBarFocusRetryRequestId != requestId else { return } + pendingAddressBarFocusRetryRequestId = requestId + pendingAddressBarFocusRetryGeneration &+= 1 + let generation = pendingAddressBarFocusRetryGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { + guard pendingAddressBarFocusRetryGeneration == generation else { return } + pendingAddressBarFocusRetryRequestId = nil + guard panel.pendingAddressBarFocusRequestId == requestId else { return } + applyPendingAddressBarFocusRequestIfNeeded() + } + } + private func applyPendingAddressBarFocusRequestIfNeeded() { - guard let requestId = panel.pendingAddressBarFocusRequestId else { return } - guard !isCommandPaletteVisibleForPanelWindow() else { return } - guard lastHandledAddressBarFocusRequestId != requestId else { return } + guard let requestId = panel.pendingAddressBarFocusRequestId else { + clearPendingAddressBarFocusRetry() + return + } + guard !isCommandPaletteVisibleForPanelWindow() else { +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply.skip", + detail: "reason=command_palette_visible request=\(requestId.uuidString.prefix(8))" + ) +#endif + schedulePendingAddressBarFocusRetryIfNeeded(requestId: requestId) + return + } + clearPendingAddressBarFocusRetry() + guard lastHandledAddressBarFocusRequestId != requestId else { +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply.skip", + detail: "reason=already_handled request=\(requestId.uuidString.prefix(8))" + ) +#endif + return + } lastHandledAddressBarFocusRequestId = requestId panel.beginSuppressWebViewFocusForAddressBar() +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8))" + ) +#endif if addressBarFocused { // Re-run focus behavior (select-all/refresh suggestions) when focus is @@ -882,11 +1045,29 @@ struct BrowserPanelView: View { let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) applyOmnibarEffects(effects) refreshInlineCompletion() +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8)) mode=refresh" + ) +#endif } else { - addressBarFocused = true + setAddressBarFocused(true, reason: "request.apply") +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8)) mode=set_focused" + ) +#endif } panel.acknowledgeAddressBarFocusRequest(requestId) +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.ack", + detail: "request=\(requestId.uuidString.prefix(8))" + ) +#endif } /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. @@ -896,15 +1077,48 @@ struct BrowserPanelView: View { } private func autoFocusOmnibarIfBlank() { - guard isFocused else { return } - guard !addressBarFocused else { return } - guard !isCommandPaletteVisibleForPanelWindow() else { return } + guard isFocused else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=panel_not_focused") +#endif + return + } + guard !addressBarFocused else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=already_focused") +#endif + return + } + guard !isCommandPaletteVisibleForPanelWindow() else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=command_palette_visible") +#endif + return + } // If a test/automation explicitly focused WebKit, don't steal focus back. - guard !panel.shouldSuppressOmnibarAutofocus() else { return } + guard !panel.shouldSuppressOmnibarAutofocus() else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=autofocus_suppressed") +#endif + return + } // If a real navigation is underway (e.g. open_browser https://...), don't steal focus. - guard !panel.webView.isLoading else { return } - guard isWebViewBlank() else { return } - addressBarFocused = true + guard !panel.webView.isLoading else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_loading") +#endif + return + } + guard isWebViewBlank() else { +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_not_blank") +#endif + return + } + setAddressBarFocused(true, reason: "autoFocus.blank") +#if DEBUG + logBrowserFocusState(event: "addressBarFocus.autoFocus.apply") +#endif } private func openDevTools() { @@ -924,13 +1138,15 @@ struct BrowserPanelView: View { } private func handleOmnibarTap() { - onRequestPanelFocus() - guard !addressBarFocused else { return } - // `focusPane` converges selection and can transiently move first responder to WebKit. - // Reassert omnibar focus on the next runloop for click-to-type behavior. - DispatchQueue.main.async { - addressBarFocused = true +#if DEBUG + logBrowserFocusState(event: "addressBar.tap") +#endif + if !addressBarFocused { + // Mark focused before pane selection converges so WebKit focus is not + // briefly re-acquired during `focusPane`. + setAddressBarFocused(true, reason: "omnibar.tap") } + onRequestPanelFocus() } private func hideSuggestions() { @@ -961,7 +1177,7 @@ struct BrowserPanelView: View { hideSuggestions() inlineCompletion = nil suppressNextFocusLostRevert = true - addressBarFocused = false + setAddressBarFocused(false, reason: "suggestion.commit") } private func handleOmnibarEscape() { @@ -1262,14 +1478,58 @@ struct BrowserPanelView: View { } if effects.shouldBlurToWebView { hideSuggestions() - addressBarFocused = false + // This transition is stateful: drop omnibar focus suppression before + // attempting responder handoff so WKWebView can actually become first responder. + panel.endSuppressWebViewFocusForAddressBar() + syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.preHandoff") + setAddressBarFocused(false, reason: "effects.blurToWebView") DispatchQueue.main.async { - guard isFocused else { return } guard let window = panel.webView.window, !panel.webView.isHiddenOrHasHiddenAncestor else { return } + guard shouldApplyAddressBarExitFallback(in: window) else { +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "result=skip_not_focused" + ) +#endif + NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + return + } + syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.handoff") panel.clearWebViewFocusSuppression() - window.makeFirstResponder(panel.webView) - NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + let focusedWebView = window.makeFirstResponder(panel.webView) +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "focusedWebView=\(focusedWebView ? 1 : 0)" + ) +#endif + panel.restoreAddressBarPageFocusIfNeeded { restored in + guard shouldApplyAddressBarExitFallback(in: window) else { +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "result=skip_stale_restore restored=\(restored ? 1 : 0)" + ) +#endif + NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + return + } + let hasWebViewResponder = + browserFocusResponderChainContains(window.firstResponder, target: panel.webView) + if !hasWebViewResponder { + let fallbackFocusedWebView = window.makeFirstResponder(panel.webView) +#if DEBUG + dlog( + "browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " + + "fallbackFocusedWebView=\(fallbackFocusedWebView ? 1 : 0) " + + "restored=\(restored ? 1 : 0)" + ) +#endif + } + NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id) + } } } } @@ -2282,10 +2542,10 @@ struct OmnibarSuggestion: Identifiable, Hashable { } func browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: Bool, + desiredOmnibarFocus: Bool, nextResponderIsOtherTextField: Bool ) -> Bool { - suppressWebViewFocus && !nextResponderIsOtherTextField + desiredOmnibarFocus && !nextResponderIsOtherTextField } private final class OmnibarNativeTextField: NSTextField { @@ -2310,7 +2570,11 @@ private final class OmnibarNativeTextField: NSTextField { override func mouseDown(with event: NSEvent) { #if DEBUG - dlog("browser.omnibarClick") + let frType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.omnibarClick win=\(window?.windowNumber ?? -1) " + + "fr=\(frType) hasEditor=\(currentEditor() == nil ? 0 : 1)" + ) #endif onPointerDown?() @@ -2318,7 +2582,14 @@ private final class OmnibarNativeTextField: NSTextField { // First click — activate editing and select all (standard URL bar behavior). // Avoids NSTextView's tracking loop which can spin forever if text layout // enters an infinite invalidation cycle (e.g. under memory pressure). - window?.makeFirstResponder(self) + let result = window?.makeFirstResponder(self) ?? false +#if DEBUG + let frAfter = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.omnibarClick.makeFirstResponder result=\(result ? 1 : 0) " + + "win=\(window?.windowNumber ?? -1) fr=\(frAfter)" + ) +#endif currentEditor()?.selectAll(nil) shiftClickAnchor = nil } else { @@ -2432,6 +2703,35 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { self.parent = parent } +#if DEBUG + func logFocusEvent(_ event: String, detail: String = "") { + let window = parentField?.window + let responder = window?.firstResponder + let responderType = responder.map { String(describing: type(of: $0)) } ?? "nil" + let responderIsField: Int = { + guard let field = parentField else { return 0 } + if responder === field { return 1 } + if let editor = responder as? NSTextView, + (editor.delegate as? NSTextField) === field { + return 1 + } + return 0 + }() + let pendingValue: String = { + guard let pendingFocusRequest else { return "nil" } + return pendingFocusRequest ? "focus" : "blur" + }() + var line = + "browser.focus.field event=\(event) focused=\(parent.isFocused ? 1 : 0) " + + "pending=\(pendingValue) suppressWeb=\(parent.shouldSuppressWebViewFocus() ? 1 : 0) " + + "win=\(window?.windowNumber ?? -1) fr=\(responderType) frIsField=\(responderIsField)" + if !detail.isEmpty { + line += " \(detail)" + } + dlog(line) + } +#endif + deinit { if let selectionObserver { NotificationCenter.default.removeObserver(selectionObserver) @@ -2454,16 +2754,77 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { return false } + private func isPointerDownEvent(_ event: NSEvent) -> Bool { + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private func topHitViewForCurrentPointerEvent(window: NSWindow) -> NSView? { + guard let event = NSApp.currentEvent, isPointerDownEvent(event) else { + return nil + } + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + + if let contentView = window.contentView, + let themeFrame = contentView.superview { + let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) + if let hitInTheme = themeFrame.hitTest(pointInTheme) { + return hitInTheme + } + } + + guard let contentView = window.contentView else { + return nil + } + let pointInContent = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(pointInContent) + } + + private func pointerDownBlurIntent(window: NSWindow?) -> Bool { + guard let window, let field = parentField else { return false } + guard let hitView = topHitViewForCurrentPointerEvent(window: window) else { + return false + } + + if hitView === field || hitView.isDescendant(of: field) { + return false + } + if let textView = hitView as? NSTextView, + let delegateField = textView.delegate as? NSTextField, + delegateField === field { + return false + } + return true + } + private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool { + if pointerDownBlurIntent(window: window) { + return false + } return browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: parent.shouldSuppressWebViewFocus(), + desiredOmnibarFocus: parent.isFocused, nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window) ) } func controlTextDidBeginEditing(_ obj: Notification) { +#if DEBUG + logFocusEvent("controlTextDidBeginEditing") +#endif if !parent.isFocused { DispatchQueue.main.async { +#if DEBUG + self.logFocusEvent("controlTextDidBeginEditing.asyncSetFocused", detail: "old=0 new=1") +#endif self.parent.isFocused = true } } @@ -2472,16 +2833,33 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } func controlTextDidEndEditing(_ obj: Notification) { +#if DEBUG + let nextOther = nextResponderIsOtherTextField(window: parentField?.window) + let pointerBlur = pointerDownBlurIntent(window: parentField?.window) + logFocusEvent( + "controlTextDidEndEditing", + detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)" + ) +#endif if parent.isFocused { if shouldReacquireFocusAfterEndEditing(window: parentField?.window) { +#if DEBUG + logFocusEvent("controlTextDidEndEditing.reacquire.begin") +#endif guard pendingFocusRequest != true else { return } pendingFocusRequest = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.pendingFocusRequest = nil +#if DEBUG + self.logFocusEvent("controlTextDidEndEditing.reacquire.tick") +#endif guard self.parent.isFocused else { return } guard let field = self.parentField, let window = field.window else { return } guard self.shouldReacquireFocusAfterEndEditing(window: window) else { +#if DEBUG + self.logFocusEvent("controlTextDidEndEditing.reacquire.cancel") +#endif self.parent.onFieldLostFocus() return } @@ -2492,11 +2870,21 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { field.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === field if !isAlreadyFocused { +#if DEBUG + self.logFocusEvent("controlTextDidEndEditing.reacquire.apply") +#endif window.makeFirstResponder(field) + } else { +#if DEBUG + self.logFocusEvent("controlTextDidEndEditing.reacquire.skip", detail: "reason=already_focused") +#endif } } return } +#if DEBUG + logFocusEvent("controlTextDidEndEditing.blur") +#endif parent.onFieldLostFocus() } detachSelectionObserver() @@ -2725,28 +3113,66 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { nsView.currentEditor() != nil || ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true { +#if DEBUG + context.coordinator.logFocusEvent( + "updateNSView.requestFocus.begin", + detail: "isFocused=1 isFirstResponder=0" + ) +#endif // Defer to avoid triggering input method XPC during layout pass, // which can crash via re-entrant view hierarchy modification. context.coordinator.pendingFocusRequest = true DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in coordinator?.pendingFocusRequest = nil guard let nsView, let window = nsView.window else { return } +#if DEBUG + if coordinator?.parent.isFocused != true { + coordinator?.logFocusEvent("updateNSView.requestFocus.cancel", detail: "reason=stale_state") + return + } +#endif + guard coordinator?.parent.isFocused == true else { return } +#if DEBUG + coordinator?.logFocusEvent("updateNSView.requestFocus.tick") +#endif let fr = window.firstResponder let alreadyFocused = fr === nsView || nsView.currentEditor() != nil || ((fr as? NSTextView)?.delegate as? NSTextField) === nsView guard !alreadyFocused else { return } +#if DEBUG + coordinator?.logFocusEvent("updateNSView.requestFocus.apply") +#endif window.makeFirstResponder(nsView) } } else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false { +#if DEBUG + context.coordinator.logFocusEvent( + "updateNSView.requestBlur.begin", + detail: "isFocused=0 isFirstResponder=1" + ) +#endif context.coordinator.pendingFocusRequest = false DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in coordinator?.pendingFocusRequest = nil guard let nsView, let window = nsView.window else { return } +#if DEBUG + if coordinator?.parent.isFocused == true { + coordinator?.logFocusEvent("updateNSView.requestBlur.cancel", detail: "reason=stale_state") + return + } +#endif + guard coordinator?.parent.isFocused == false else { return } +#if DEBUG + coordinator?.logFocusEvent("updateNSView.requestBlur.tick") +#endif let fr = window.firstResponder let stillFirst = fr === nsView || ((fr as? NSTextView)?.delegate as? NSTextField) === nsView guard stillFirst else { return } +#if DEBUG + coordinator?.logFocusEvent("updateNSView.requestBlur.apply") +#endif window.makeFirstResponder(nil) } } @@ -3091,17 +3517,27 @@ struct WebViewRepresentable: NSViewRepresentable { var desiredPortalVisibleInUI: Bool = true var desiredPortalZPriority: Int = 0 var lastPortalHostId: ObjectIdentifier? + var lastSynchronizedHostGeometryRevision: UInt64 = 0 } final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? + private(set) var geometryRevision: UInt64 = 0 + private var lastReportedGeometryState: GeometryState? private struct HostedInspectorDividerHit { let containerView: NSView let pageView: NSView let inspectorView: NSView } + private struct GeometryState: Equatable { + let frame: CGRect + let bounds: CGRect + let windowNumber: Int? + let superviewID: ObjectIdentifier? + } + private struct HostedInspectorDividerDragState { let containerView: NSView let pageView: NSView @@ -3129,6 +3565,13 @@ struct WebViewRepresentable: NSViewRepresentable { private var hasLoggedMissingHostedInspectorCandidate = false #endif + deinit { + if let trackingArea { + removeTrackingArea(trackingArea) + } + clearActiveDividerCursor(restoreArrow: false) + } + #if DEBUG private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool { switch event?.type { @@ -3225,6 +3668,23 @@ struct WebViewRepresentable: NSViewRepresentable { abs(lhs.height - rhs.height) <= epsilon } + private func currentGeometryState() -> GeometryState { + GeometryState( + frame: frame, + bounds: bounds, + windowNumber: window?.windowNumber, + superviewID: superview.map(ObjectIdentifier.init) + ) + } + + private func notifyGeometryChangedIfNeeded() { + let state = currentGeometryState() + guard state != lastReportedGeometryState else { return } + lastReportedGeometryState = state + geometryRevision &+= 1 + onGeometryChanged?() + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window == nil { @@ -3234,7 +3694,7 @@ struct WebViewRepresentable: NSViewRepresentable { } window?.invalidateCursorRects(for: self) onDidMoveToWindow?() - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow") #endif @@ -3243,7 +3703,7 @@ struct WebViewRepresentable: NSViewRepresentable { override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToSuperview") - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview") #endif @@ -3252,7 +3712,7 @@ struct WebViewRepresentable: NSViewRepresentable { override func layout() { super.layout() reapplyHostedInspectorDividerIfNeeded(reason: "layout") - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "layout") #endif @@ -3262,7 +3722,7 @@ struct WebViewRepresentable: NSViewRepresentable { super.setFrameOrigin(newOrigin) window?.invalidateCursorRects(for: self) reapplyHostedInspectorDividerIfNeeded(reason: "setFrameOrigin") - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin") #endif @@ -3272,7 +3732,7 @@ struct WebViewRepresentable: NSViewRepresentable { super.setFrameSize(newSize) window?.invalidateCursorRects(for: self) reapplyHostedInspectorDividerIfNeeded(reason: "setFrameSize") - onGeometryChanged?() + notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize") #endif @@ -3848,6 +4308,7 @@ struct WebViewRepresentable: NSViewRepresentable { BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) coordinator.lastPortalHostId = ObjectIdentifier(host) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } host.onGeometryChanged = { [weak host, weak coordinator, weak portalAnchorView] in guard let host, let coordinator, let portalAnchorView else { return } @@ -3855,6 +4316,7 @@ struct WebViewRepresentable: NSViewRepresentable { guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } Self.installPortalAnchorView(portalAnchorView, in: host) BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } if !shouldAttachWebView { @@ -3865,6 +4327,7 @@ struct WebViewRepresentable: NSViewRepresentable { if host.window != nil { let hostId = ObjectIdentifier(host) + let geometryRevision = host.geometryRevision let shouldBindNow = coordinator.lastPortalHostId != hostId || webView.superview == nil || @@ -3879,13 +4342,18 @@ struct WebViewRepresentable: NSViewRepresentable { zPriority: coordinator.desiredPortalZPriority ) coordinator.lastPortalHostId = hostId + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } BrowserWindowPortalRegistry.updatePaneTopChromeHeight( for: webView, height: shouldAttachWebView ? paneTopChromeHeight : 0 ) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) - BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + if !shouldBindNow, + coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } } else { // Bind is deferred until host moves into a window. Keep the current // portal entry's desired state in sync so stale callbacks cannot keep @@ -3930,6 +4398,7 @@ struct WebViewRepresentable: NSViewRepresentable { if let previousWebView = coordinator.webView, previousWebView !== webView { BrowserWindowPortalRegistry.detach(webView: previousWebView) coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 } coordinator.panel = panel coordinator.webView = webView @@ -3959,20 +4428,53 @@ struct WebViewRepresentable: NSViewRepresentable { isPanelFocused: Bool ) { // Focus handling. Avoid fighting the address bar when it is focused. - guard let window = nsView.window else { return } + guard let window = nsView.window else { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=no_window shouldFocus=\(shouldFocusWebView ? 1 : 0) " + + "panelFocused=\(isPanelFocused ? 1 : 0)" + ) +#endif + return + } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=suppressed panelFocused=\(isPanelFocused ? 1 : 0)" + ) +#endif return } if responderChainContains(window.firstResponder, target: webView) { +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=skip reason=already_first_responder_chain" + ) +#endif return } - window.makeFirstResponder(webView) + let result = window.makeFirstResponder(webView) +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" + ) +#endif } else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) { // Only force-resign WebView focus when this panel itself is not focused. // If the panel is focused but the omnibar-focus state is briefly stale, aggressively // clearing first responder here can undo programmatic webview focus (socket tests). - window.makeFirstResponder(nil) + let result = window.makeFirstResponder(nil) +#if DEBUG + dlog( + "browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " + + "action=resign result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))" + ) +#endif } } @@ -4032,6 +4534,7 @@ struct WebViewRepresentable: NSViewRepresentable { BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil) coordinator.lastPortalHostId = nil + coordinator.lastSynchronizedHostGeometryRevision = 0 } private func currentPaneDropContext() -> BrowserPaneDropContext? { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 723dedb9..ed00bbd9 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -93,7 +93,7 @@ final class CmuxWebView: WKWebView { /// Temporarily permits focus acquisition for explicit pointer-driven interactions /// (mouse click into this webview) while keeping background autofocus blocked. - func withPointerFocusAllowance(_ body: () -> Void) { + func withPointerFocusAllowance(_ body: () -> T) -> T { pointerFocusAllowanceDepth += 1 #if DEBUG dlog( @@ -110,7 +110,7 @@ final class CmuxWebView: WKWebView { ) #endif } - body() + return body() } override func performKeyEquivalent(with event: NSEvent) -> Bool { diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index b96325db..dc8d7c6c 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -86,15 +86,17 @@ struct MarkdownPanelView: View { Image(systemName: "doc.questionmark") .font(.system(size: 40)) .foregroundColor(.secondary) - Text("File unavailable") + Text(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable")) .font(.headline) .foregroundColor(.primary) Text(panel.filePath) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) - .lineLimit(2) .multilineTextAlignment(.center) - Text("The file may have been moved or deleted.") + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 24) + Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "The file may have been moved or deleted.")) .font(.caption) .foregroundColor(.secondary) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2c2efbfc..0920d588 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2880,7 +2880,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh() + terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f2b72af4..1f134353 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4161,7 +4161,7 @@ class TerminalController { var refreshedCount = 0 for panel in ws.panels.values { if let terminalPanel = panel as? TerminalPanel { - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceRefresh") refreshedCount += 1 } } @@ -4243,7 +4243,7 @@ class TerminalController { // Ensure we present a new frame after injecting input so snapshot-based tests (and // socket-driven agents) can observe the updated terminal without requiring a focus // change to trigger a draw. - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") queued = false } else { // Avoid blocking the main actor waiting for view/surface attachment. @@ -4301,7 +4301,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) return } - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result @@ -4333,7 +4333,7 @@ class TerminalController { return } - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceClearHistory") let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "workspace_id": ws.id.uuidString, @@ -9704,81 +9704,91 @@ class TerminalController { return "OK" } - private func simulateShortcut(_ args: String) -> String { - let combo = args.trimmingCharacters(in: .whitespacesAndNewlines) - guard !combo.isEmpty else { - return "ERROR: Usage: simulate_shortcut " - } - guard let parsed = parseShortcutCombo(combo) else { - return "ERROR: Invalid combo. Example: cmd+ctrl+h" - } + private func prepareWindowForSyntheticInput(_ window: NSWindow?) { + guard let window else { return } - // Stamp at socket-handler arrival so event.timestamp includes any wait - // before the main-thread event dispatch. - let requestTimestamp = ProcessInfo.processInfo.systemUptime - - var result = "ERROR: Failed to create event" - DispatchQueue.main.sync { - // Prefer the current active-tab-manager window so shortcut simulation stays - // scoped to the intended window even when NSApp.keyWindow is stale. - let targetWindow: NSWindow? = { - if let activeTabManager = self.tabManager, - let windowId = AppDelegate.shared?.windowId(for: activeTabManager), - let window = AppDelegate.shared?.mainWindow(for: windowId) { - return window - } - return NSApp.keyWindow - ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - ?? NSApp.windows.first - }() - if let targetWindow { - NSApp.activate(ignoringOtherApps: true) - targetWindow.makeKeyAndOrderFront(nil) - } - let windowNumber = targetWindow?.windowNumber ?? 0 - guard let keyDownEvent = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: parsed.modifierFlags, - timestamp: requestTimestamp, - windowNumber: windowNumber, - context: nil, - characters: parsed.characters, - charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, - isARepeat: false, - keyCode: parsed.keyCode - ) else { - result = "ERROR: NSEvent.keyEvent returned nil" - return - } - let keyUpEvent = NSEvent.keyEvent( - with: .keyUp, - location: .zero, - modifierFlags: parsed.modifierFlags, - timestamp: requestTimestamp + 0.0001, - windowNumber: windowNumber, - context: nil, - characters: parsed.characters, - charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, - isARepeat: false, - keyCode: parsed.keyCode - ) - // Socket-driven shortcut simulation should reuse the exact same matching logic as the - // app-level shortcut monitor (so tests are hermetic), while still falling back to the - // normal responder chain for plain typing. - if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) { - result = "OK" - return - } - NSApp.sendEvent(keyDownEvent) - if let keyUpEvent { - NSApp.sendEvent(keyUpEvent) - } - result = "OK" - } - return result - } + // Keep socket-driven input simulation focused on the intended window without + // paying repeated activation/order-front costs for every synthetic key event. + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + if !window.isKeyWindow || !window.isVisible { + window.makeKeyAndOrderFront(nil) + } + } + + private func simulateShortcut(_ args: String) -> String { + let combo = args.trimmingCharacters(in: .whitespacesAndNewlines) + guard !combo.isEmpty else { + return "ERROR: Usage: simulate_shortcut " + } + guard let parsed = parseShortcutCombo(combo) else { + return "ERROR: Invalid combo. Example: cmd+ctrl+h" + } + + // Stamp at socket-handler arrival so event.timestamp includes any wait + // before the main-thread event dispatch. + let requestTimestamp = ProcessInfo.processInfo.systemUptime + + var result = "ERROR: Failed to create event" + DispatchQueue.main.sync { + // Prefer the current active-tab-manager window so shortcut simulation stays + // scoped to the intended window even when NSApp.keyWindow is stale. + let targetWindow: NSWindow? = { + if let activeTabManager = self.tabManager, + let windowId = AppDelegate.shared?.windowId(for: activeTabManager), + let window = AppDelegate.shared?.mainWindow(for: windowId) { + return window + } + return NSApp.keyWindow + ?? NSApp.mainWindow + ?? NSApp.windows.first(where: { $0.isVisible }) + ?? NSApp.windows.first + }() + prepareWindowForSyntheticInput(targetWindow) + let windowNumber = targetWindow?.windowNumber ?? 0 + guard let keyDownEvent = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: parsed.modifierFlags, + timestamp: requestTimestamp, + windowNumber: windowNumber, + context: nil, + characters: parsed.characters, + charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, + isARepeat: false, + keyCode: parsed.keyCode + ) else { + result = "ERROR: NSEvent.keyEvent returned nil" + return + } + let keyUpEvent = NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: parsed.modifierFlags, + timestamp: requestTimestamp + 0.0001, + windowNumber: windowNumber, + context: nil, + characters: parsed.characters, + charactersIgnoringModifiers: parsed.charactersIgnoringModifiers, + isARepeat: false, + keyCode: parsed.keyCode + ) + // Socket-driven shortcut simulation should reuse the exact same matching logic as the + // app-level shortcut monitor (so tests are hermetic), while still falling back to the + // normal responder chain for plain typing. + if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) { + result = "OK" + return + } + NSApp.sendEvent(keyDownEvent) + if let keyUpEvent { + NSApp.sendEvent(keyUpEvent) + } + result = "OK" + } + return result + } private func activateApp() -> String { DispatchQueue.main.sync { @@ -9823,8 +9833,7 @@ class TerminalController { ?? NSApp.mainWindow ?? NSApp.windows.first(where: { $0.isVisible }) ?? NSApp.windows.first else { return } - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) + prepareWindowForSyntheticInput(window) guard let fr = window.firstResponder else { result = "ERROR: No first responder" return @@ -11146,7 +11155,7 @@ class TerminalController { var cgImage = view.debugCopyIOSurfaceCGImage() if cgImage == nil { // If the surface is mid-attach we may not have contents yet. Nudge a draw and retry once. - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.debugCopyIOSurfaceRetry") cgImage = view.debugCopyIOSurfaceCGImage() } guard let cgImage else { @@ -13712,7 +13721,7 @@ class TerminalController { // (resets cached metrics so the Metal layer drawable resizes correctly) for panel in tab.panels.values { if let terminalPanel = panel as? TerminalPanel { - terminalPanel.surface.forceRefresh() + terminalPanel.surface.forceRefresh(reason: "terminalController.refreshAllTerminalPanels") refreshedCount += 1 } } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 56a1783f..80c0d2ba 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -54,6 +54,13 @@ final class WindowTerminalHostView: NSView { private var lastDragRouteSignature: String? #endif + deinit { + if let trackingArea { + removeTrackingArea(trackingArea) + } + clearActiveDividerCursor(restoreArrow: false) + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window == nil { @@ -698,12 +705,13 @@ final class WindowTerminalPortal: NSObject { synchronizeAllHostedViews(excluding: nil) // During live resize, AppKit can deliver frame churn where host/container geometry - // settles a tick before the terminal's own scroll/surface hierarchy. Force a final - // in-place geometry + surface refresh for all visible entries in this window. + // settles a tick before the terminal's own scroll/surface hierarchy. Only force an + // in-place surface refresh when reconciliation actually changed terminal geometry. for entry in entriesByHostedId.values { guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue } - hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow() + if hostedView.reconcileGeometryNow() { + hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync") + } } } @@ -1392,7 +1400,7 @@ final class WindowTerminalPortal: NSObject { hostedView.frame = targetFrame CATransaction.commit() hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow() + hostedView.refreshSurfaceNow(reason: "portal.frameChange") } if hasFiniteFrame { @@ -1431,7 +1439,7 @@ final class WindowTerminalPortal: NSObject { // normal frame-change refresh path won't run. Nudge geometry + redraw so newly // revealed terminals don't sit on a stale/blank IOSurface until later focus churn. hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow() + hostedView.refreshSurfaceNow(reason: "portal.reveal") } if transientRecoveryReason == nil { diff --git a/Sources/Update/UpdatePill.swift b/Sources/Update/UpdatePill.swift index f8d69aff..ed43c192 100644 --- a/Sources/Update/UpdatePill.swift +++ b/Sources/Update/UpdatePill.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import Foundation import SwiftUI @@ -54,7 +55,7 @@ struct UpdatePill: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .help(model.text) + .safeHelp(model.text) .accessibilityLabel(model.text) .accessibilityIdentifier("UpdatePill") } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 12af22b6..1e4795ac 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -273,7 +273,7 @@ struct TitlebarControlsView: View { } var body: some View { - // Force the `.help(...)` tooltips to re-evaluate when shortcuts are changed in settings. + // Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings. // (The titlebar controls don't otherwise re-render on UserDefaults changes.) let _ = shortcutRefreshTick let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic @@ -321,7 +321,7 @@ struct TitlebarControlsView: View { } .accessibilityIdentifier("titlebarControl.toggleSidebar") .accessibilityLabel(String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar")) - .help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) + .safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) TitlebarControlButton(config: config, action: { #if DEBUG @@ -348,7 +348,7 @@ struct TitlebarControlsView: View { .accessibilityIdentifier("titlebarControl.showNotifications") .background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }) .accessibilityLabel(String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications")) - .help(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications"))) + .safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications"))) TitlebarControlButton(config: config, action: { #if DEBUG @@ -360,7 +360,7 @@ struct TitlebarControlsView: View { } .accessibilityIdentifier("titlebarControl.newTab") .accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace")) - .help(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace"))) + .safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace"))) } let paddedContent = content.padding(config.groupPadding) @@ -729,6 +729,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont view = containerView containerView.translatesAutoresizingMaskIntoConstraints = true + // Prevent the titlebar accessory from clipping button backgrounds + // at the bottom edge (the system constrains accessory height to the + // titlebar, which can be slightly shorter than the button frames). + containerView.wantsLayer = true + containerView.layer?.masksToBounds = false hostingView.translatesAutoresizingMaskIntoConstraints = true hostingView.autoresizingMask = [.width, .height] containerView.addSubview(hostingView) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 58d88702..7a4bb58a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3432,11 +3432,11 @@ final class Workspace: Identifiable, ObservableObject { needsFollowUpPass = true } - hostedView.reconcileGeometryNow() + let geometryChanged = hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). - if terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh() + if geometryChanged, terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -3492,9 +3492,9 @@ final class Workspace: Identifiable, ObservableObject { let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { guard let self, let panel = self.terminalPanel(for: panelId) else { return } - panel.hostedView.reconcileGeometryNow() - if panel.surface.surface != nil { - panel.surface.forceRefresh() + let geometryChanged = panel.hostedView.reconcileGeometryNow() + if geometryChanged, panel.surface.surface != nil { + panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") } if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index adb78505..8526ceba 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -10745,6 +10745,61 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { ) } + func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let anchor = NSView(frame: anchorFrame) + contentView.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let offWindowContainer = NSView(frame: anchorFrame) + anchor.removeFromSuperview() + offWindowContainer.addSubview(anchor) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Off-window anchor reparent should preserve the hosted browser slot during drag churn" + ) + XCTAssertFalse( + slot.isHidden, + "Off-window anchor reparent should keep the visible browser portal alive until the anchor returns" + ) + XCTAssertEqual(portal.debugEntryCount(), 1) + + contentView.addSubview(anchor) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot") + XCTAssertFalse(slot.isHidden) + XCTAssertEqual(portal.debugEntryCount(), 1) + } + func testRegistryDetachRemovesPortalHostedWebView() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -11498,10 +11553,10 @@ final class TerminalControllerSocketTextChunkTests: XCTestCase { } final class BrowserOmnibarFocusPolicyTests: XCTestCase { - func testReacquiresFocusWhenWebViewSuppressionIsActiveAndNextResponderIsNotAnotherTextField() { + func testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() { XCTAssertTrue( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: true, + desiredOmnibarFocus: true, nextResponderIsOtherTextField: false ) ) @@ -11510,16 +11565,16 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase { func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { XCTAssertFalse( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: true, + desiredOmnibarFocus: true, nextResponderIsOtherTextField: true ) ) } - func testDoesNotReacquireFocusWhenWebViewSuppressionIsInactive() { + func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() { XCTAssertFalse( browserOmnibarShouldReacquireFocusAfterEndEditing( - suppressWebViewFocus: false, + desiredOmnibarFocus: false, nextResponderIsOtherTextField: false ) ) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift new file mode 100644 index 00000000..fd9ada43 --- /dev/null +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -0,0 +1,621 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class CommandPaletteSearchEngineTests: XCTestCase { + private struct FixtureEntry { + let id: String + let rank: Int + let title: String + let searchableTexts: [String] + } + + private struct FixtureResult: Equatable { + let id: String + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set + } + + private func makeCommandEntries(count: Int) -> [FixtureEntry] { + (0.. [FixtureEntry] { + (0.. [FixtureEntry] { + [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + FixtureEntry( + id: "command.filter", + rank: 2, + title: "Filter Sidebar Items", + searchableTexts: ["Filter Sidebar Items", "Sidebar", "filter", "sidebar", "items"] + ), + ] + } + + private func optimizedResults( + entries: [FixtureEntry], + query: String + ) -> [FixtureResult] { + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + + return CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + .map { + FixtureResult( + id: $0.payload, + rank: $0.rank, + title: $0.title, + score: $0.score, + titleMatchIndices: $0.titleMatchIndices + ) + } + } + + private func legacyResults( + entries: [FixtureEntry], + query: String + ) -> [FixtureResult] { + let queryIsEmpty = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let results: [FixtureResult] = queryIsEmpty + ? entries.map { entry in + FixtureResult(id: entry.id, rank: entry.rank, title: entry.title, score: 0, titleMatchIndices: []) + } + : entries.compactMap { entry in + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + query: query, + candidates: entry.searchableTexts + ) else { + return nil + } + return FixtureResult( + id: entry.id, + rank: entry.rank, + title: entry.title, + score: fuzzyScore, + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: query, + candidate: entry.title + ) + ) + } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private func benchmarkElapsedMs(operation: () -> Void) -> Double { + let start = DispatchTime.now().uptimeNanoseconds + operation() + let elapsed = DispatchTime.now().uptimeNanoseconds - start + return Double(elapsed) / 1_000_000 + } + + private func repeatedQueries(_ baseQueries: [String], repetitions: Int) -> [String] { + Array(repeating: baseQueries, count: repetitions).flatMap { $0 } + } + + func testOptimizedSearchMatchesLegacyPipeline() { + let commandEntries = makeCommandEntries(count: 96) + let switcherEntries = makeSwitcherEntries(count: 64) + let queries = [ + "rename", + "rename tab", + "workspace", + "feature-12", + "3004", + "toggle side", + "open dir", + "phoenix", + "apply update", + ] + + for query in queries { + XCTAssertEqual( + optimizedResults(entries: commandEntries, query: query), + legacyResults(entries: commandEntries, query: query), + "Command corpus mismatch for query \(query)" + ) + XCTAssertEqual( + optimizedResults(entries: switcherEntries, query: query), + legacyResults(entries: switcherEntries, query: query), + "Switcher corpus mismatch for query \(query)" + ) + } + } + + func testSearchCancellationReturnsNoResults() { + let entries = makeCommandEntries(count: 512) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + var cancellationChecks = 0 + + let results = CommandPaletteSearchEngine.search( + entries: corpus, + query: "rename" + ) { _, _ in + 0 + } shouldCancel: { + cancellationChecks += 1 + return cancellationChecks >= 4 + } + + XCTAssertTrue(results.isEmpty) + XCTAssertGreaterThanOrEqual(cancellationChecks, 4) + } + + func testCommandPreviewSearchUsesFullCommandCorpus() { + let entries = [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + ] + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) + + let previewCommandIDs = ContentView.commandPaletteCommandPreviewMatchCommandIDsForTests( + searchCorpus: corpus, + candidateCommandIDs: ["command.find"], + searchCorpusByID: corpusByID, + query: "finde", + resultLimit: 48 + ) + + XCTAssertEqual(previewCommandIDs.first, "command.finder") + } + + func testSearchMatchesSingleOmittedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "findr").first?.id, + "command.finder" + ) + } + + func testSearchMatchesSingleInsertedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "findder").first?.id, + "command.finder" + ) + } + + func testSearchMatchesSingleSubstitutedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "fander").first?.id, + "command.finder" + ) + } + + func testSearchMatchesSingleTransposedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "fidner").first?.id, + "command.finder" + ) + } + + func testSearchRejectsMultipleEditsInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertNotEqual( + optimizedResults(entries: entries, query: "fadnr").first?.id, + "command.finder" + ) + } + + func testResolvedSelectionIndexPrefersAnchoredCommand() { + let resultIDs = ["command.0", "command.1", "command.2"] + + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: "command.2", + fallbackSelectedIndex: 0, + resultIDs: resultIDs + ), + 2 + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: "missing", + fallbackSelectedIndex: 9, + resultIDs: resultIDs + ), + 2 + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: nil, + fallbackSelectedIndex: 1, + resultIDs: [] + ), + 0 + ) + } + + func testResolvedPendingActivationPreservesSubmitAndClickSemantics() { + let resultIDs = ["command.0", "command.1", "command.2"] + + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .selected(requestID: 41, fallbackSelectedIndex: 0, preferredCommandID: "command.2"), + requestID: 41, + resultIDs: resultIDs + ), + .selected(index: 2) + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .command(requestID: 41, commandID: "command.1"), + requestID: 41, + resultIDs: resultIDs + ), + .command(commandID: "command.1") + ) + XCTAssertNil( + ContentView.commandPaletteResolvedPendingActivation( + .command(requestID: 41, commandID: "missing"), + requestID: 41, + resultIDs: resultIDs + ) + ) + XCTAssertNil( + ContentView.commandPaletteResolvedPendingActivation( + .selected(requestID: 40, fallbackSelectedIndex: 0, preferredCommandID: nil), + requestID: 41, + resultIDs: resultIDs + ) + ) + } + + func testSelectionAnchorTracksVisiblePendingSelection() { + let resultIDs = ["command.0", "command.1", "command.2"] + let visibleAnchor = ContentView.commandPaletteSelectionAnchorCommandID( + selectedIndex: 2, + resultIDs: resultIDs + ) + + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .selected( + requestID: 41, + fallbackSelectedIndex: 0, + preferredCommandID: visibleAnchor + ), + requestID: 41, + resultIDs: resultIDs + ), + .selected(index: 2) + ) + } + + func testPreviewCandidateCommandIDsAreBounded() { + let resultIDs = (0..<500).map { "command.\($0)" } + + let previewCandidateIDs = ContentView.commandPalettePreviewCandidateCommandIDs( + resultIDs: resultIDs, + limit: 192 + ) + + XCTAssertEqual(previewCandidateIDs.count, 192) + XCTAssertEqual(previewCandidateIDs.first, "command.0") + XCTAssertEqual(previewCandidateIDs.last, "command.191") + } + + func testSynchronousSeedRunsOnlyWhenScopeChanges() { + XCTAssertTrue( + ContentView.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: false + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldSynchronouslySeedResults( + hasVisibleResultsForScope: true + ) + ) + } + + func testCommandContextFingerprintTracksExactContextValues() { + let base = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": false, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Main", + ] + ) + let unreadChanged = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": true, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Main", + ] + ) + let renamed = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": false, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Logs", + ] + ) + + XCTAssertNotEqual(base, unreadChanged) + XCTAssertNotEqual(base, renamed) + } + + func testSwitcherFingerprintTracksMetadataValuesAtSameCardinality() { + let windowID = UUID() + let workspaceID = UUID() + let base = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm"], + branches: ["feature/search-speed"], + ports: [3000] + ) + ) + ] + ) + ] + ) + let changedMetadata = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/other"], + branches: ["feature/search-speed"], + ports: [4000] + ) + ) + ] + ) + ] + ) + let changedDisplayName = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Beta", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm"], + branches: ["feature/search-speed"], + ports: [3000] + ) + ) + ] + ) + ] + ) + + XCTAssertNotEqual(base, changedMetadata) + XCTAssertNotEqual(base, changedDisplayName) + } + + func testCommandSearchBenchmarkBeatsLegacyPipeline() { + let entries = makeCommandEntries(count: 900) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + ["rename", "rename tab", "open dir", "toggle side", "apply update", "notif", "split right", "cmux"], + repetitions: 12 + ) + + for query in queries.prefix(8) { + _ = legacyResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let legacyMs = benchmarkElapsedMs { + for query in queries { + _ = legacyResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+shift+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) + XCTAssertLessThan( + optimizedMs, + legacyMs * 1.25, + "Optimized command search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)" + ) + } + + func testSwitcherSearchBenchmarkBeatsLegacyPipeline() { + let entries = makeSwitcherEntries(count: 400) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + ["workspace 12", "phoenix", "feature-18", "rename-tab", "3007", "9202", "switch", "worktrees"], + repetitions: 12 + ) + + for query in queries.prefix(8) { + _ = legacyResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let legacyMs = benchmarkElapsedMs { + for query in queries { + _ = legacyResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) + XCTAssertLessThan( + optimizedMs, + legacyMs * 1.25, + "Optimized switcher search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)" + ) + } +} diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index f1c6b630..5f76cb57 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -171,6 +171,154 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testEscapeRestoresFocusedPageInputAfterCmdL() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1" + launchAndEnsureForeground(app) + + XCTAssertTrue( + waitForData( + keys: [ + "browserPanelId", + "webViewFocused", + "webInputFocusSeeded", + "webInputFocusElementId", + "webInputFocusSecondaryElementId", + "webInputFocusSecondaryClickOffsetX", + "webInputFocusSecondaryClickOffsetY" + ], + timeout: 12.0 + ), + "Expected setup data including focused page input to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing goto_split setup data") + return + } + + XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") + XCTAssertEqual(setup["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+L") + + guard let expectedInputId = setup["webInputFocusElementId"], !expectedInputId.isEmpty else { + XCTFail("Missing webInputFocusElementId in setup data") + return + } + guard let expectedSecondaryInputId = setup["webInputFocusSecondaryElementId"], !expectedSecondaryInputId.isEmpty else { + XCTFail("Missing webInputFocusSecondaryElementId in setup data") + return + } + guard let secondaryClickOffsetXRaw = setup["webInputFocusSecondaryClickOffsetX"], + let secondaryClickOffsetYRaw = setup["webInputFocusSecondaryClickOffsetY"], + let secondaryClickOffsetX = Double(secondaryClickOffsetXRaw), + let secondaryClickOffsetY = Double(secondaryClickOffsetYRaw) else { + XCTFail( + "Missing or invalid secondary input click offsets in setup data. " + + "webInputFocusSecondaryClickOffsetX=\(setup["webInputFocusSecondaryClickOffsetX"] ?? "nil") " + + "webInputFocusSecondaryClickOffsetY=\(setup["webInputFocusSecondaryClickOffsetY"] ?? "nil")" + ) + return + } + + app.typeKey("l", modifierFlags: [.command]) + XCTAssertTrue( + waitForDataMatch(timeout: 5.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" + }, + "Expected Cmd+L to focus omnibar" + ) + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if !waitForDataMatch(timeout: 2.0, predicate: { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedInputId && + data["addressBarExitActiveElementEditable"] == "true" + }) { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + } + + let restoredExpectedInput = waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedInputId && + data["addressBarExitActiveElementEditable"] == "true" + } + if !restoredExpectedInput { + let snapshot = loadData() ?? [:] + XCTFail( + "Expected Escape to restore focus to the previously focused page input. " + + "expectedInputId=\(expectedInputId) " + + "webViewFocusedAfterAddressBarExit=\(snapshot["webViewFocusedAfterAddressBarExit"] ?? "nil") " + + "addressBarExitActiveElementId=\(snapshot["addressBarExitActiveElementId"] ?? "nil") " + + "addressBarExitActiveElementTag=\(snapshot["addressBarExitActiveElementTag"] ?? "nil") " + + "addressBarExitActiveElementType=\(snapshot["addressBarExitActiveElementType"] ?? "nil") " + + "addressBarExitActiveElementEditable=\(snapshot["addressBarExitActiveElementEditable"] ?? "nil") " + + "addressBarExitTrackedFocusStateId=\(snapshot["addressBarExitTrackedFocusStateId"] ?? "nil") " + + "addressBarExitFocusTrackerInstalled=\(snapshot["addressBarExitFocusTrackerInstalled"] ?? "nil") " + + "addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " + + "addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " + + "addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil") " + + "webInputFocusElementId=\(snapshot["webInputFocusElementId"] ?? "nil") " + + "webInputFocusTrackerInstalled=\(snapshot["webInputFocusTrackerInstalled"] ?? "nil") " + + "webInputFocusTrackedStateId=\(snapshot["webInputFocusTrackedStateId"] ?? "nil")" + ) + } + + let window = app.windows.firstMatch + XCTAssertTrue( + window.waitForExistence(timeout: 6.0), + "Expected app window for post-escape click regression check" + ) + + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + window + .coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) + .withOffset(CGVector(dx: secondaryClickOffsetX, dy: secondaryClickOffsetY)) + .click() + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + + app.typeKey("l", modifierFlags: [.command]) + let clickMovedFocusToSecondary = waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarFocus"] == "false" && + data["addressBarFocusActiveElementId"] == expectedSecondaryInputId && + data["addressBarFocusActiveElementEditable"] == "true" + } + if !clickMovedFocusToSecondary { + let snapshot = loadData() ?? [:] + XCTFail( + "Expected post-escape click to focus secondary page input before Cmd+L. " + + "secondaryInputId=\(expectedSecondaryInputId) " + + "addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " + + "addressBarFocusActiveElementTag=\(snapshot["addressBarFocusActiveElementTag"] ?? "nil") " + + "addressBarFocusActiveElementType=\(snapshot["addressBarFocusActiveElementType"] ?? "nil") " + + "addressBarFocusActiveElementEditable=\(snapshot["addressBarFocusActiveElementEditable"] ?? "nil") " + + "addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " + + "addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil")" + ) + } + + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + if !waitForDataMatch(timeout: 2.0, predicate: { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedSecondaryInputId && + data["addressBarExitActiveElementEditable"] == "true" + }) { + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + } + + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["webViewFocusedAfterAddressBarExit"] == "true" && + data["addressBarExitActiveElementId"] == expectedSecondaryInputId && + data["addressBarExitActiveElementEditable"] == "true" + }, + "Expected Escape to restore focus to the clicked secondary page input" + ) + } + func testCmdLOpensBrowserWhenTerminalFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath diff --git a/design/cmux.icon/Assets/cmux-icon-chevron 2.png b/design/cmux.icon/Assets/cmux-icon-chevron 2.png new file mode 100644 index 00000000..9e5f23f1 Binary files /dev/null and b/design/cmux.icon/Assets/cmux-icon-chevron 2.png differ diff --git a/design/cmux.icon/icon.json b/design/cmux.icon/icon.json new file mode 100644 index 00000000..e4ddba51 --- /dev/null +++ b/design/cmux.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill" : "automatic", + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "cmux-icon-chevron 2.png", + "name" : "cmux-icon-chevron 2", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 37.357790031201375, + -0.5 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/scripts/download-prebuilt-ghosttykit.sh b/scripts/download-prebuilt-ghosttykit.sh new file mode 100755 index 00000000..cc3c520b --- /dev/null +++ b/scripts/download-prebuilt-ghosttykit.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [ -n "${GHOSTTY_SHA:-}" ]; then + GHOSTTY_SHA="$GHOSTTY_SHA" +else + if [ ! -d "$REPO_ROOT/ghostty" ] || ! git -C "$REPO_ROOT/ghostty" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Missing ghostty submodule. Run ./scripts/setup.sh or git submodule update --init --recursive first." >&2 + exit 1 + fi + GHOSTTY_SHA="$(git -C "$REPO_ROOT/ghostty" rev-parse HEAD)" +fi + +TAG="xcframework-$GHOSTTY_SHA" +ARCHIVE_NAME="${GHOSTTYKIT_ARCHIVE_NAME:-GhosttyKit.xcframework.tar.gz}" +OUTPUT_DIR="${GHOSTTYKIT_OUTPUT_DIR:-GhosttyKit.xcframework}" +CHECKSUMS_FILE="${GHOSTTYKIT_CHECKSUMS_FILE:-$SCRIPT_DIR/ghosttykit-checksums.txt}" +DOWNLOAD_URL="${GHOSTTYKIT_URL:-https://github.com/manaflow-ai/ghostty/releases/download/$TAG/$ARCHIVE_NAME}" +DOWNLOAD_RETRIES="${GHOSTTYKIT_DOWNLOAD_RETRIES:-30}" +DOWNLOAD_RETRY_DELAY="${GHOSTTYKIT_DOWNLOAD_RETRY_DELAY:-20}" + +if [ ! -f "$CHECKSUMS_FILE" ]; then + echo "Missing checksum file: $CHECKSUMS_FILE" >&2 + exit 1 +fi + +EXPECTED_SHA256="$( + awk -v sha="$GHOSTTY_SHA" ' + $1 == sha { + print $2 + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "$CHECKSUMS_FILE" || true +)" + +if [ -z "$EXPECTED_SHA256" ]; then + echo "Missing pinned GhosttyKit checksum for ghostty $GHOSTTY_SHA in $CHECKSUMS_FILE" >&2 + exit 1 +fi + +echo "Downloading $ARCHIVE_NAME for ghostty $GHOSTTY_SHA" +curl --fail --show-error --location \ + --retry "$DOWNLOAD_RETRIES" \ + --retry-delay "$DOWNLOAD_RETRY_DELAY" \ + --retry-all-errors \ + -o "$ARCHIVE_NAME" \ + "$DOWNLOAD_URL" + +ACTUAL_SHA256="$(shasum -a 256 "$ARCHIVE_NAME" | awk '{print $1}')" +if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then + echo "$ARCHIVE_NAME checksum mismatch" >&2 + echo "Expected: $EXPECTED_SHA256" >&2 + echo "Actual: $ACTUAL_SHA256" >&2 + exit 1 +fi + +rm -rf "$OUTPUT_DIR" +tar xzf "$ARCHIVE_NAME" +rm "$ARCHIVE_NAME" +test -d "$OUTPUT_DIR" + +echo "Verified and extracted $OUTPUT_DIR" diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt new file mode 100644 index 00000000..29794d12 --- /dev/null +++ b/scripts/ghosttykit-checksums.txt @@ -0,0 +1,4 @@ +# Pinned GhosttyKit.xcframework.tar.gz checksums keyed by ghostty submodule SHA. +# Update this file in a reviewed PR whenever the ghostty submodule SHA changes. +# Format: +7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 diff --git a/tests/test_ci_ghosttykit_checksum_verification.sh b/tests/test_ci_ghosttykit_checksum_verification.sh new file mode 100755 index 00000000..1eba6ecf --- /dev/null +++ b/tests/test_ci_ghosttykit_checksum_verification.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Regression test for the pinned GhosttyKit artifact verification helper. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/scripts/download-prebuilt-ghosttykit.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +WORKFLOWS=( + "$ROOT_DIR/.github/workflows/ci.yml" + "$ROOT_DIR/.github/workflows/nightly.yml" + "$ROOT_DIR/.github/workflows/release.yml" +) + +FIXTURE_SHA="7dd589824d4c9bda8265355718800cccaf7189a0" +FIXTURE_DIR="$TMP_DIR/fixture" +SUCCESS_DIR="$TMP_DIR/success" +MISMATCH_DIR="$TMP_DIR/mismatch" +MISSING_ENTRY_DIR="$TMP_DIR/missing-entry" +BIN_DIR="$TMP_DIR/bin" +CHECKSUMS_FILE="$TMP_DIR/ghosttykit-checksums.txt" +SUCCESS_LOG="$TMP_DIR/curl-success.log" +MISMATCH_LOG="$TMP_DIR/curl-mismatch.log" +MISMATCH_OUTPUT="$TMP_DIR/mismatch.out" +MISSING_ENTRY_OUTPUT="$TMP_DIR/missing-entry.out" + +mkdir -p "$FIXTURE_DIR/GhosttyKit.xcframework" "$SUCCESS_DIR" "$MISMATCH_DIR" "$MISSING_ENTRY_DIR" "$BIN_DIR" +printf 'fixture\n' > "$FIXTURE_DIR/GhosttyKit.xcframework/marker.txt" +(cd "$FIXTURE_DIR" && tar czf "$TMP_DIR/GhosttyKit.xcframework.tar.gz" GhosttyKit.xcframework) +ACTUAL_SHA256="$(shasum -a 256 "$TMP_DIR/GhosttyKit.xcframework.tar.gz" | awk '{print $1}')" +printf '%s %s\n' "$FIXTURE_SHA" "$ACTUAL_SHA256" > "$CHECKSUMS_FILE" + +for workflow in "${WORKFLOWS[@]}"; do + if ! grep -Fq './scripts/download-prebuilt-ghosttykit.sh' "$workflow"; then + echo "FAIL: $workflow must call download-prebuilt-ghosttykit.sh" + exit 1 + fi +done + +cat > "$BIN_DIR/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +LOG_FILE="${TEST_CURL_LOG:?}" +FIXTURE_ARCHIVE="${TEST_FIXTURE_ARCHIVE:?}" +OUTPUT="" + +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + OUTPUT="$2" + shift 2 + ;; + *) + printf '%s\n' "$1" >> "$LOG_FILE" + shift + ;; + esac +done + +if [ -z "$OUTPUT" ]; then + echo "curl stub missing -o output path" >&2 + exit 1 +fi + +cp "$FIXTURE_ARCHIVE" "$OUTPUT" +EOF +chmod +x "$BIN_DIR/curl" + +( + cd "$SUCCESS_DIR" + PATH="$BIN_DIR:$PATH" \ + TEST_CURL_LOG="$SUCCESS_LOG" \ + TEST_FIXTURE_ARCHIVE="$TMP_DIR/GhosttyKit.xcframework.tar.gz" \ + GHOSTTY_SHA="$FIXTURE_SHA" \ + GHOSTTYKIT_CHECKSUMS_FILE="$CHECKSUMS_FILE" \ + "$SCRIPT" +) + +if [ ! -f "$SUCCESS_DIR/GhosttyKit.xcframework/marker.txt" ]; then + echo "FAIL: verification helper did not extract GhosttyKit.xcframework" + exit 1 +fi + +if [ -f "$SUCCESS_DIR/GhosttyKit.xcframework.tar.gz" ]; then + echo "FAIL: verification helper did not clean up the downloaded archive" + exit 1 +fi + +for expected_arg in --retry --retry-delay --retry-all-errors; do + if ! grep -Fxq -- "$expected_arg" "$SUCCESS_LOG"; then + echo "FAIL: curl invocation missing $expected_arg" + exit 1 + fi +done + +printf '%s %s\n' "$FIXTURE_SHA" "0000000000000000000000000000000000000000000000000000000000000000" > "$CHECKSUMS_FILE" + +if ( + cd "$MISMATCH_DIR" + PATH="$BIN_DIR:$PATH" \ + TEST_CURL_LOG="$MISMATCH_LOG" \ + TEST_FIXTURE_ARCHIVE="$TMP_DIR/GhosttyKit.xcframework.tar.gz" \ + GHOSTTY_SHA="$FIXTURE_SHA" \ + GHOSTTYKIT_CHECKSUMS_FILE="$CHECKSUMS_FILE" \ + "$SCRIPT" +) >"$MISMATCH_OUTPUT" 2>&1; then + echo "FAIL: verification helper succeeded with an invalid pinned checksum" + exit 1 +fi + +if ! grep -Fq "GhosttyKit.xcframework.tar.gz checksum mismatch" "$MISMATCH_OUTPUT"; then + echo "FAIL: verification helper did not report checksum mismatch" + exit 1 +fi + +printf '%s %s\n' "0000000000000000000000000000000000000000" "$ACTUAL_SHA256" > "$CHECKSUMS_FILE" + +if ( + cd "$MISSING_ENTRY_DIR" + PATH="$BIN_DIR:$PATH" \ + TEST_CURL_LOG="$MISMATCH_LOG" \ + TEST_FIXTURE_ARCHIVE="$TMP_DIR/GhosttyKit.xcframework.tar.gz" \ + GHOSTTY_SHA="$FIXTURE_SHA" \ + GHOSTTYKIT_CHECKSUMS_FILE="$CHECKSUMS_FILE" \ + "$SCRIPT" +) >"$MISSING_ENTRY_OUTPUT" 2>&1; then + echo "FAIL: verification helper succeeded without a pinned checksum entry" + exit 1 +fi + +if ! grep -Fq "Missing pinned GhosttyKit checksum for ghostty $FIXTURE_SHA" "$MISSING_ENTRY_OUTPUT"; then + echo "FAIL: verification helper did not report a missing pinned checksum entry" + exit 1 +fi + +echo "PASS: GhosttyKit verification helper enforces pinned checksums" diff --git a/vendor/bonsplit b/vendor/bonsplit index 89a4fd12..fa452db1 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 89a4fd1288a706ae4b766f323191d6570b7123aa +Subproject commit fa452db181f361514087558a29204bda7e38218f