Merge remote-tracking branch 'origin/main' into fix-popover-arrow

This commit is contained in:
cmux 2026-03-07 18:52:16 -08:00
commit 7fc71fc7cc
66 changed files with 4235 additions and 440 deletions

View file

@ -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-*

View file

@ -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

View file

@ -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'

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

35
AppIcon.icon/icon.json Normal file
View 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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 B

After

Width:  |  Height:  |  Size: 622 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

After

Width:  |  Height:  |  Size: 587 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 591 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 659 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 659 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Before After
Before After

View file

@ -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;
};

View file

@ -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

View file

@ -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": "ファイルを利用できません"
}
}
}
}
}
}

View file

@ -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?) {

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}

View file

@ -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.

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -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)
}

View file

@ -2880,7 +2880,7 @@ class TabManager: ObservableObject {
continue
}
terminal.hostedView.reconcileGeometryNow()
terminal.surface.forceRefresh()
terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry")
}
}

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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")
}

View file

@ -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)

View file

@ -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()

View file

@ -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
)
)

View 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)"
)
}
}

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

View 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"
}
}

View 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"

View 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

View 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