Merge remote-tracking branch 'origin/main' into fix-popover-arrow
53
.github/workflows/ci.yml
vendored
|
|
@ -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-*
|
||||
|
|
|
|||
25
.github/workflows/nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
25
.github/workflows/release.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
BIN
AppIcon.icon/Assets/cmux-icon-chevron 2.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
35
AppIcon.icon/icon.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 738 B After Width: | Height: | Size: 622 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 610 B After Width: | Height: | Size: 587 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 555 B After Width: | Height: | Size: 591 B |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 659 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 122 KiB |
BIN
Assets.xcassets/AppIconDark.imageset/AppIconDark.png
vendored
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 659 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 404 KiB |
|
|
@ -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 = "<group>"; };
|
||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
|
||||
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
|
||||
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "ファイルを利用できません"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "<invalid>"
|
||||
}
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.string ?? "<redacted>"
|
||||
}
|
||||
#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?) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<SurfaceSearchOverlay>?
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<T>(_ 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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2880,7 +2880,7 @@ class TabManager: ObservableObject {
|
|||
continue
|
||||
}
|
||||
terminal.hostedView.reconcileGeometryNow()
|
||||
terminal.surface.forceRefresh()
|
||||
terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <combo>"
|
||||
}
|
||||
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 <combo>"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
621
cmuxTests/CommandPaletteSearchEngineTests.swift
Normal file
|
|
@ -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<Int>
|
||||
}
|
||||
|
||||
private func makeCommandEntries(count: Int) -> [FixtureEntry] {
|
||||
(0..<count).map { index in
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let keywords: [String]
|
||||
|
||||
switch index % 8 {
|
||||
case 0:
|
||||
title = "Rename Workspace \(index)"
|
||||
subtitle = "Workspace"
|
||||
keywords = ["rename", "workspace", "title", "project", "switch"]
|
||||
case 1:
|
||||
title = "Rename Tab \(index)"
|
||||
subtitle = "Tab"
|
||||
keywords = ["rename", "tab", "surface", "title"]
|
||||
case 2:
|
||||
title = "Open Current Directory in IDE \(index)"
|
||||
subtitle = "Terminal"
|
||||
keywords = ["open", "directory", "cwd", "ide", "vscode"]
|
||||
case 3:
|
||||
title = "Toggle Sidebar \(index)"
|
||||
subtitle = "Layout"
|
||||
keywords = ["toggle", "sidebar", "layout", "panel"]
|
||||
case 4:
|
||||
title = "Apply Update If Available \(index)"
|
||||
subtitle = "Global"
|
||||
keywords = ["apply", "update", "install", "upgrade"]
|
||||
case 5:
|
||||
title = "Restart CLI Listener \(index)"
|
||||
subtitle = "Global"
|
||||
keywords = ["restart", "cli", "listener", "socket", "cmux"]
|
||||
case 6:
|
||||
title = "Show Notifications \(index)"
|
||||
subtitle = "Notifications"
|
||||
keywords = ["notifications", "inbox", "unread", "alerts"]
|
||||
default:
|
||||
title = "Split Browser Right \(index)"
|
||||
subtitle = "Layout"
|
||||
keywords = ["split", "browser", "right", "layout", "web"]
|
||||
}
|
||||
|
||||
return FixtureEntry(
|
||||
id: "command.\(index)",
|
||||
rank: index,
|
||||
title: title,
|
||||
searchableTexts: [title, subtitle] + keywords
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSwitcherEntries(count: Int) -> [FixtureEntry] {
|
||||
(0..<count).map { index in
|
||||
let title = "Workspace \(index) Phoenix"
|
||||
let keywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace", "switch", "go", title],
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feature-\(index)-rename-tab"],
|
||||
branches: ["feature/rename-tab-\(index)"],
|
||||
ports: [3000 + (index % 20), 9200 + (index % 5)]
|
||||
),
|
||||
detail: .workspace
|
||||
)
|
||||
return FixtureEntry(
|
||||
id: "workspace.\(index)",
|
||||
rank: index,
|
||||
title: title,
|
||||
searchableTexts: [title, "Workspace"] + keywords
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeFinderCommandEntries() -> [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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
BIN
design/cmux.icon/Assets/cmux-icon-chevron 2.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
35
design/cmux.icon/icon.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
71
scripts/download-prebuilt-ghosttykit.sh
Executable file
|
|
@ -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"
|
||||
4
scripts/ghosttykit-checksums.txt
Normal file
|
|
@ -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: <ghostty_sha> <sha256>
|
||||
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
|
||||
138
tests/test_ci_ghosttykit_checksum_verification.sh
Executable file
|
|
@ -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"
|
||||
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 89a4fd1288a706ae4b766f323191d6570b7123aa
|
||||
Subproject commit fa452db181f361514087558a29204bda7e38218f
|
||||