Merge remote-tracking branch 'origin/main' into feature/sidebar-pr-metadata
# Conflicts: # Sources/ContentView.swift # Sources/Workspace.swift
This commit is contained in:
commit
f28eb00b31
92 changed files with 22498 additions and 734 deletions
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -7,6 +7,15 @@ on:
|
|||
pull_request:
|
||||
|
||||
jobs:
|
||||
workflow-guard-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate self-hosted runner guards
|
||||
run: ./tests/test_ci_self_hosted_guard.sh
|
||||
|
||||
web-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
|
@ -26,6 +35,8 @@ jobs:
|
|||
run: bun tsc --noEmit
|
||||
|
||||
ui-tests:
|
||||
# Never run self-hosted jobs for fork pull requests.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: self-hosted
|
||||
concurrency:
|
||||
group: self-hosted-build
|
||||
|
|
|
|||
13
.github/workflows/nightly.yml
vendored
13
.github/workflows/nightly.yml
vendored
|
|
@ -294,6 +294,19 @@ jobs:
|
|||
# by appcast URLs to prevent signature/asset mismatch races.
|
||||
cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE"
|
||||
|
||||
- name: Upload dSYMs to Sentry
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: manaflow
|
||||
SENTRY_PROJECT: cmuxterm-macos
|
||||
run: |
|
||||
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
|
||||
echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload"
|
||||
exit 0
|
||||
fi
|
||||
brew install getsentry/tools/sentry-cli || true
|
||||
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
|
||||
|
||||
- name: Generate Sparkle appcast (nightly)
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
|
|
|
|||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -250,6 +250,20 @@ jobs:
|
|||
xcrun stapler staple "$DMG_RELEASE"
|
||||
xcrun stapler validate "$DMG_RELEASE"
|
||||
|
||||
- name: Upload dSYMs to Sentry
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: manaflow
|
||||
SENTRY_PROJECT: cmuxterm-macos
|
||||
run: |
|
||||
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
|
||||
echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload"
|
||||
exit 0
|
||||
fi
|
||||
brew install getsentry/tools/sentry-cli || true
|
||||
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
|
||||
|
||||
- name: Generate Sparkle appcast
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -2007,6 +2007,54 @@ struct CMUXCLI {
|
|||
}
|
||||
}
|
||||
|
||||
func displayBrowserValue(_ value: Any) -> String {
|
||||
if value is NSNull {
|
||||
return "null"
|
||||
}
|
||||
if let string = value as? String {
|
||||
return string
|
||||
}
|
||||
if let bool = value as? Bool {
|
||||
return bool ? "true" : "false"
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
return number.stringValue
|
||||
}
|
||||
if JSONSerialization.isValidJSONObject(value),
|
||||
let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return text
|
||||
}
|
||||
return String(describing: value)
|
||||
}
|
||||
|
||||
func displayBrowserLogItems(_ value: Any?) -> String? {
|
||||
guard let items = value as? [Any], !items.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let lines = items.map { item -> String in
|
||||
guard let dict = item as? [String: Any] else {
|
||||
return displayBrowserValue(item)
|
||||
}
|
||||
|
||||
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let levelRaw = (dict["level"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let level = levelRaw.isEmpty ? "log" : levelRaw
|
||||
|
||||
if text.isEmpty {
|
||||
if let message = (dict["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!message.isEmpty {
|
||||
return "[error] \(message)"
|
||||
}
|
||||
return displayBrowserValue(dict)
|
||||
}
|
||||
return "[\(level)] \(text)"
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func nonFlagArgs(_ values: [String]) -> [String] {
|
||||
values.filter { !$0.hasPrefix("-") }
|
||||
}
|
||||
|
|
@ -2174,7 +2222,13 @@ struct CMUXCLI {
|
|||
throw CLIError(message: "browser eval requires a script")
|
||||
}
|
||||
let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed])
|
||||
output(payload, fallback: "OK")
|
||||
let fallback: String
|
||||
if let value = payload["value"] {
|
||||
fallback = displayBrowserValue(value)
|
||||
} else {
|
||||
fallback = "OK"
|
||||
}
|
||||
output(payload, fallback: fallback)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2785,7 +2839,8 @@ struct CMUXCLI {
|
|||
throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)")
|
||||
}
|
||||
let payload = try client.sendV2(method: method, params: ["surface_id": sid])
|
||||
output(payload, fallback: "OK")
|
||||
let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK"
|
||||
output(payload, fallback: fallback)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2799,7 +2854,8 @@ struct CMUXCLI {
|
|||
throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)")
|
||||
}
|
||||
let payload = try client.sendV2(method: "browser.errors.list", params: params)
|
||||
output(payload, fallback: "OK")
|
||||
let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK"
|
||||
output(payload, fallback: fallback)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; };
|
||||
A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; };
|
||||
A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; };
|
||||
A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; };
|
||||
A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; };
|
||||
A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; };
|
||||
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
|
||||
|
|
@ -54,11 +55,13 @@
|
|||
A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; };
|
||||
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; };
|
||||
A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; };
|
||||
A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; };
|
||||
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
|
||||
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
|
||||
B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; };
|
||||
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; };
|
||||
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; };
|
||||
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; };
|
||||
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; };
|
||||
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; };
|
||||
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
|
||||
|
|
@ -71,11 +74,14 @@
|
|||
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
|
||||
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
|
||||
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
|
||||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
A5001020 /* Embed Frameworks */ = {
|
||||
|
|
@ -96,6 +102,7 @@
|
|||
files = (
|
||||
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */,
|
||||
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */,
|
||||
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */,
|
||||
);
|
||||
name = "Copy CLI";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -144,6 +151,7 @@
|
|||
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
||||
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
||||
A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; };
|
||||
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; };
|
||||
A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; };
|
||||
A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -177,11 +185,13 @@
|
|||
A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = "<group>"; };
|
||||
A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = "<group>"; };
|
||||
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
|
||||
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
|
||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.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>"; };
|
||||
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; };
|
||||
A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; };
|
||||
B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = "<group>"; };
|
||||
B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
|
@ -190,14 +200,17 @@
|
|||
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = "<group>"; };
|
||||
B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = "<group>"; };
|
||||
B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A5001030 /* Frameworks */ = {
|
||||
|
|
@ -319,6 +332,7 @@
|
|||
A5001019 /* TerminalController.swift */,
|
||||
A5001541 /* PortScanner.swift */,
|
||||
A5001225 /* SocketControlSettings.swift */,
|
||||
A5001600 /* SentryHelper.swift */,
|
||||
A5001090 /* AppDelegate.swift */,
|
||||
A5001091 /* NotificationsPage.swift */,
|
||||
A5001092 /* TerminalNotificationStore.swift */,
|
||||
|
|
@ -345,6 +359,7 @@
|
|||
A5001219 /* WindowToolbarController.swift */,
|
||||
A5001241 /* WindowDecorationsController.swift */,
|
||||
A5001222 /* WindowAccessor.swift */,
|
||||
A5001611 /* SessionPersistence.swift */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -395,17 +410,20 @@
|
|||
path = cmuxUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -548,6 +566,7 @@
|
|||
A5001007 /* TerminalController.swift in Sources */,
|
||||
A5001540 /* PortScanner.swift in Sources */,
|
||||
A5001226 /* SocketControlSettings.swift in Sources */,
|
||||
A5001601 /* SentryHelper.swift in Sources */,
|
||||
A5001093 /* AppDelegate.swift in Sources */,
|
||||
A5001094 /* NotificationsPage.swift in Sources */,
|
||||
A5001095 /* TerminalNotificationStore.swift in Sources */,
|
||||
|
|
@ -574,6 +593,7 @@
|
|||
A5001209 /* WindowToolbarController.swift in Sources */,
|
||||
A5001240 /* WindowDecorationsController.swift in Sources */,
|
||||
A500120C /* WindowAccessor.swift in Sources */,
|
||||
A5001610 /* SessionPersistence.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -594,18 +614,21 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F1000005A1B2C3D4E5F60718 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9000006A1B2C3D4E5F60719 /* Sources */ = {
|
||||
F1000005A1B2C3D4E5F60718 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
|
||||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9000006A1B2C3D4E5F60719 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -194,6 +194,16 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in
|
|||
|
||||
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
|
|
|
|||
283
Resources/bin/open
Executable file
283
Resources/bin/open
Executable file
|
|
@ -0,0 +1,283 @@
|
|||
#!/usr/bin/env bash
|
||||
# cmux open wrapper - routes HTTP(S) URLs to cmux's in-app browser
|
||||
#
|
||||
# When running inside a cmux terminal (CMUX_SOCKET_PATH is set), this wrapper
|
||||
# intercepts `open https://...` invocations and opens them in cmux's built-in
|
||||
# browser within the same workspace. All other arguments pass through to
|
||||
# /usr/bin/open unchanged.
|
||||
|
||||
SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}"
|
||||
DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}"
|
||||
PYTHON3_BIN="${CMUX_OPEN_WRAPPER_PYTHON3:-}"
|
||||
|
||||
if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then
|
||||
SYSTEM_OPEN_BIN="/usr/bin/open"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$DEFAULTS_BIN" ]]; then
|
||||
DEFAULTS_BIN="/usr/bin/defaults"
|
||||
fi
|
||||
|
||||
if [[ -n "$PYTHON3_BIN" ]]; then
|
||||
if [[ ! -x "$PYTHON3_BIN" ]]; then
|
||||
PYTHON3_BIN=""
|
||||
fi
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON3_BIN="$(command -v python3)"
|
||||
fi
|
||||
|
||||
settings_domain="${CMUX_BUNDLE_ID:-}"
|
||||
whitelist_raw=""
|
||||
whitelist_patterns=()
|
||||
|
||||
system_open() {
|
||||
exec "$SYSTEM_OPEN_BIN" "$@"
|
||||
}
|
||||
|
||||
trim() {
|
||||
local value="$1"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
to_lower_ascii() {
|
||||
# Bash 3.2-compatible lowercase conversion.
|
||||
LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
normalize_boolean() {
|
||||
to_lower_ascii "$(trim "$1")"
|
||||
}
|
||||
|
||||
is_false_setting() {
|
||||
local normalized
|
||||
normalized="$(normalize_boolean "$1")"
|
||||
case "$normalized" in
|
||||
0|false|no|off)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
canonicalize_idn_host() {
|
||||
local value="$1"
|
||||
[[ -z "$PYTHON3_BIN" ]] && {
|
||||
printf '%s' "$value"
|
||||
return 0
|
||||
}
|
||||
|
||||
local canonicalized
|
||||
canonicalized="$("$PYTHON3_BIN" - "$value" <<'PY' 2>/dev/null || true
|
||||
import sys
|
||||
|
||||
host = sys.argv[1].strip().rstrip(".")
|
||||
if not host:
|
||||
raise SystemExit(1)
|
||||
|
||||
labels = host.split(".")
|
||||
if any(not label for label in labels):
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
canonical = ".".join(label.encode("idna").decode("ascii") for label in labels)
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
|
||||
sys.stdout.write(canonical.lower())
|
||||
PY
|
||||
)"
|
||||
if [[ -n "$canonicalized" ]]; then
|
||||
printf '%s' "$canonicalized"
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
is_http_url() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
[Hh][Tt][Tt][Pp]://*|[Hh][Tt][Tt][Pp][Ss]://*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
normalize_host() {
|
||||
local value
|
||||
value="$(trim "$1")"
|
||||
value="$(to_lower_ascii "$value")"
|
||||
[[ -z "$value" ]] && return 1
|
||||
|
||||
if [[ "$value" == *"://"* ]]; then
|
||||
value="${value#*://}"
|
||||
fi
|
||||
|
||||
value="${value%%/*}"
|
||||
value="${value%%\?*}"
|
||||
value="${value%%\#*}"
|
||||
|
||||
if [[ "$value" == *"@"* ]]; then
|
||||
value="${value##*@}"
|
||||
fi
|
||||
|
||||
if [[ "$value" == \[* ]]; then
|
||||
value="${value#\[}"
|
||||
value="${value%%\]*}"
|
||||
elif [[ "$value" == *:* ]]; then
|
||||
local colons="${value//[^:]}"
|
||||
if [[ ${#colons} -eq 1 ]] && [[ "$value" =~ :[0-9]+$ ]]; then
|
||||
value="${value%:*}"
|
||||
fi
|
||||
fi
|
||||
|
||||
while [[ "$value" == .* ]]; do
|
||||
value="${value#.}"
|
||||
done
|
||||
while [[ "$value" == *. ]]; do
|
||||
value="${value%.}"
|
||||
done
|
||||
|
||||
[[ -z "$value" ]] && return 1
|
||||
value="$(canonicalize_idn_host "$value")"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
normalize_whitelist_pattern() {
|
||||
local value
|
||||
value="$(trim "$1")"
|
||||
value="$(to_lower_ascii "$value")"
|
||||
[[ -z "$value" ]] && return 1
|
||||
|
||||
if [[ "$value" == \*.* ]]; then
|
||||
local suffix
|
||||
suffix="$(normalize_host "${value#*.}")" || return 1
|
||||
printf '*.%s' "$suffix"
|
||||
return 0
|
||||
fi
|
||||
|
||||
normalize_host "$value"
|
||||
}
|
||||
|
||||
host_matches_pattern() {
|
||||
local host="$1"
|
||||
local pattern="$2"
|
||||
|
||||
if [[ "$pattern" == \*.* ]]; then
|
||||
local suffix="${pattern#*.}"
|
||||
[[ "$host" == "$suffix" ]] && return 0
|
||||
[[ "$host" == *".$suffix" ]] && return 0
|
||||
return 1
|
||||
fi
|
||||
|
||||
[[ "$host" == "$pattern" ]]
|
||||
}
|
||||
|
||||
host_matches_whitelist() {
|
||||
local url="$1"
|
||||
if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local host
|
||||
host="$(normalize_host "$url")" || return 1
|
||||
for pattern in "${whitelist_patterns[@]}"; do
|
||||
if host_matches_pattern "$host" "$pattern"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
load_whitelist_patterns() {
|
||||
local raw="$1"
|
||||
local line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
local normalized
|
||||
normalized="$(normalize_whitelist_pattern "$line")" || continue
|
||||
whitelist_patterns+=("$normalized")
|
||||
done <<< "$raw"
|
||||
}
|
||||
|
||||
# Pass through immediately if not in a cmux terminal.
|
||||
if [[ -z "$CMUX_SOCKET_PATH" ]]; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
# No arguments → pass through.
|
||||
if [[ $# -eq 0 ]]; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
# Scan for flags that indicate explicit user intent → pass through.
|
||||
# Also collect non-flag arguments (potential URLs/files).
|
||||
passthrough=false
|
||||
urls=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr)
|
||||
passthrough=true
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
# Unknown flag → be conservative, pass through
|
||||
passthrough=true
|
||||
break
|
||||
;;
|
||||
*)
|
||||
if is_http_url "$arg"; then
|
||||
urls+=("$arg")
|
||||
else
|
||||
# Non-URL, non-flag argument (file path, etc.) → pass through all
|
||||
passthrough=true
|
||||
break
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
# Respect the same settings used for terminal link clicks.
|
||||
if [[ -n "$settings_domain" ]]; then
|
||||
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserInterceptTerminalOpenCommandInCmuxBrowser 2>/dev/null || true)"
|
||||
if [[ -z "$open_in_cmux" ]]; then
|
||||
# Backward compatibility for installs that predate the dedicated open-wrapper toggle.
|
||||
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)"
|
||||
fi
|
||||
if is_false_setting "$open_in_cmux"; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)"
|
||||
if [[ -n "$whitelist_raw" ]]; then
|
||||
load_whitelist_patterns "$whitelist_raw"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Find cmux CLI (same directory as this script).
|
||||
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CMUX_CLI="$SELF_DIR/cmux"
|
||||
|
||||
if [[ ! -x "$CMUX_CLI" ]]; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
# Open each URL in cmux's in-app browser; track failures individually.
|
||||
failed_urls=()
|
||||
for url in "${urls[@]}"; do
|
||||
if ! host_matches_whitelist "$url"; then
|
||||
failed_urls+=("$url")
|
||||
continue
|
||||
fi
|
||||
"$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
|
||||
done
|
||||
|
||||
# Fall back to system open only for URLs that failed.
|
||||
if [[ ${#failed_urls[@]} -gt 0 ]]; then
|
||||
system_open "${failed_urls[@]}"
|
||||
fi
|
||||
|
|
@ -23,6 +23,18 @@ _cmux_send() {
|
|||
fi
|
||||
}
|
||||
|
||||
_cmux_restore_scrollback_once() {
|
||||
local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}"
|
||||
[[ -n "$path" ]] || return 0
|
||||
unset CMUX_RESTORE_SCROLLBACK_FILE
|
||||
|
||||
if [[ -r "$path" ]]; then
|
||||
/bin/cat -- "$path" 2>/dev/null || true
|
||||
/bin/rm -f -- "$path" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
_cmux_restore_scrollback_once
|
||||
|
||||
# Throttle heavy work to avoid prompt latency.
|
||||
_CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
|
||||
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ _cmux_send() {
|
|||
fi
|
||||
}
|
||||
|
||||
_cmux_restore_scrollback_once() {
|
||||
local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}"
|
||||
[[ -n "$path" ]] || return 0
|
||||
unset CMUX_RESTORE_SCROLLBACK_FILE
|
||||
|
||||
if [[ -r "$path" ]]; then
|
||||
/bin/cat -- "$path" 2>/dev/null || true
|
||||
/bin/rm -f -- "$path" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
_cmux_restore_scrollback_once
|
||||
|
||||
# Throttle heavy work to avoid prompt latency.
|
||||
typeset -g _CMUX_PWD_LAST_PWD=""
|
||||
typeset -g _CMUX_GIT_LAST_PWD=""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -117,6 +117,9 @@ final class WindowBrowserHostView: NSView {
|
|||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
updateDividerCursor(at: point)
|
||||
|
||||
if shouldPassThroughToTitlebar(at: point) {
|
||||
return nil
|
||||
}
|
||||
if shouldPassThroughToSidebarResizer(at: point) {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -127,6 +130,18 @@ final class WindowBrowserHostView: NSView {
|
|||
return hitView === self ? nil : hitView
|
||||
}
|
||||
|
||||
private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool {
|
||||
guard let window else { return false }
|
||||
// Window-level portal hosts sit above SwiftUI content. Never intercept
|
||||
// hits that land in native titlebar space or the custom titlebar strip
|
||||
// we reserve directly under it for window drag/double-click behaviors.
|
||||
let windowPoint = convert(point, to: nil)
|
||||
let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height
|
||||
let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight))
|
||||
let interactionBandMinY = window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5
|
||||
return windowPoint.y >= interactionBandMinY
|
||||
}
|
||||
|
||||
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
|
||||
// Browser portal host sits above SwiftUI content. Allow pointer/mouse events
|
||||
// to reach the SwiftUI sidebar divider resizer zone.
|
||||
|
|
@ -326,6 +341,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
private weak var installedContainerView: NSView?
|
||||
private weak var installedReferenceView: NSView?
|
||||
private var hasDeferredFullSyncScheduled = false
|
||||
private var hasExternalGeometrySyncScheduled = false
|
||||
private var geometryObservers: [NSObjectProtocol] = []
|
||||
|
||||
private struct Entry {
|
||||
weak var webView: WKWebView?
|
||||
|
|
@ -345,9 +362,73 @@ final class WindowBrowserPortal: NSObject {
|
|||
hostView.layer?.masksToBounds = true
|
||||
hostView.translatesAutoresizingMaskIntoConstraints = true
|
||||
hostView.autoresizingMask = []
|
||||
installGeometryObservers(for: window)
|
||||
_ = ensureInstalled()
|
||||
}
|
||||
|
||||
private func installGeometryObservers(for window: NSWindow) {
|
||||
guard geometryObservers.isEmpty else { return }
|
||||
|
||||
let center = NotificationCenter.default
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSWindow.didResizeNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSWindow.didEndLiveResizeNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSSplitView.didResizeSubviewsNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self,
|
||||
let splitView = notification.object as? NSSplitView,
|
||||
let window = self.window,
|
||||
splitView.window === window else { return }
|
||||
self.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func removeGeometryObservers() {
|
||||
for observer in geometryObservers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
geometryObservers.removeAll()
|
||||
}
|
||||
|
||||
private func scheduleExternalGeometrySynchronize() {
|
||||
guard !hasExternalGeometrySyncScheduled else { return }
|
||||
hasExternalGeometrySyncScheduled = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.hasExternalGeometrySyncScheduled = false
|
||||
self.synchronizeAllEntriesFromExternalGeometryChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeAllEntriesFromExternalGeometryChange() {
|
||||
guard ensureInstalled() else { return }
|
||||
installedContainerView?.layoutSubtreeIfNeeded()
|
||||
installedReferenceView?.layoutSubtreeIfNeeded()
|
||||
hostView.superview?.layoutSubtreeIfNeeded()
|
||||
hostView.layoutSubtreeIfNeeded()
|
||||
synchronizeAllWebViews(excluding: nil, source: "externalGeometry")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func ensureInstalled() -> Bool {
|
||||
guard let window else { return false }
|
||||
|
|
@ -419,13 +500,32 @@ final class WindowBrowserPortal: NSObject {
|
|||
return false
|
||||
}
|
||||
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
|
||||
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
||||
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
||||
abs(lhs.size.width - rhs.size.width) <= epsilon &&
|
||||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
|
||||
guard rect.origin.x.isFinite,
|
||||
rect.origin.y.isFinite,
|
||||
rect.size.width.isFinite,
|
||||
rect.size.height.isFinite else {
|
||||
return rect
|
||||
}
|
||||
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
|
||||
func snap(_ value: CGFloat) -> CGFloat {
|
||||
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
|
||||
}
|
||||
return NSRect(
|
||||
x: snap(rect.origin.x),
|
||||
y: snap(rect.origin.y),
|
||||
width: max(0, snap(rect.size.width)),
|
||||
height: max(0, snap(rect.size.height))
|
||||
)
|
||||
}
|
||||
|
||||
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
frame.minX < bounds.minX - epsilon ||
|
||||
frame.minY < bounds.minY - epsilon ||
|
||||
|
|
@ -765,7 +865,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
|
||||
_ = synchronizeHostFrameToReference()
|
||||
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
||||
let frameInHost = hostView.convert(frameInWindow, from: nil)
|
||||
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
|
||||
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
|
||||
let hostBounds = hostView.bounds
|
||||
let hasFiniteHostBounds =
|
||||
hostBounds.origin.x.isFinite &&
|
||||
|
|
@ -838,6 +939,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
CATransaction.setDisableActions(true)
|
||||
containerView.frame = targetFrame
|
||||
CATransaction.commit()
|
||||
webView.needsLayout = true
|
||||
webView.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
|
||||
|
|
@ -952,6 +1055,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
}
|
||||
|
||||
func tearDown() {
|
||||
removeGeometryObservers()
|
||||
for webViewId in Array(entriesByWebViewId.keys) {
|
||||
detachWebView(withId: webViewId)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ enum KeyboardShortcutSettings {
|
|||
case toggleSidebar
|
||||
case newTab
|
||||
case newWindow
|
||||
case closeWindow
|
||||
case showNotifications
|
||||
case jumpToUnread
|
||||
case triggerFlash
|
||||
|
|
@ -17,6 +18,7 @@ enum KeyboardShortcutSettings {
|
|||
case prevSurface
|
||||
case nextSidebarTab
|
||||
case prevSidebarTab
|
||||
case renameTab
|
||||
case renameWorkspace
|
||||
case closeWorkspace
|
||||
case newSurface
|
||||
|
|
@ -43,6 +45,7 @@ enum KeyboardShortcutSettings {
|
|||
case .toggleSidebar: return "Toggle Sidebar"
|
||||
case .newTab: return "New Workspace"
|
||||
case .newWindow: return "New Window"
|
||||
case .closeWindow: return "Close Window"
|
||||
case .showNotifications: return "Show Notifications"
|
||||
case .jumpToUnread: return "Jump to Latest Unread"
|
||||
case .triggerFlash: return "Flash Focused Panel"
|
||||
|
|
@ -50,6 +53,7 @@ enum KeyboardShortcutSettings {
|
|||
case .prevSurface: return "Previous Surface"
|
||||
case .nextSidebarTab: return "Next Workspace"
|
||||
case .prevSidebarTab: return "Previous Workspace"
|
||||
case .renameTab: return "Rename Tab"
|
||||
case .renameWorkspace: return "Rename Workspace"
|
||||
case .closeWorkspace: return "Close Workspace"
|
||||
case .newSurface: return "New Surface"
|
||||
|
|
@ -72,11 +76,13 @@ enum KeyboardShortcutSettings {
|
|||
case .toggleSidebar: return "shortcut.toggleSidebar"
|
||||
case .newTab: return "shortcut.newTab"
|
||||
case .newWindow: return "shortcut.newWindow"
|
||||
case .closeWindow: return "shortcut.closeWindow"
|
||||
case .showNotifications: return "shortcut.showNotifications"
|
||||
case .jumpToUnread: return "shortcut.jumpToUnread"
|
||||
case .triggerFlash: return "shortcut.triggerFlash"
|
||||
case .nextSidebarTab: return "shortcut.nextSidebarTab"
|
||||
case .prevSidebarTab: return "shortcut.prevSidebarTab"
|
||||
case .renameTab: return "shortcut.renameTab"
|
||||
case .renameWorkspace: return "shortcut.renameWorkspace"
|
||||
case .closeWorkspace: return "shortcut.closeWorkspace"
|
||||
case .focusLeft: return "shortcut.focusLeft"
|
||||
|
|
@ -104,6 +110,8 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
|
||||
case .newWindow:
|
||||
return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false)
|
||||
case .closeWindow:
|
||||
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
|
||||
case .showNotifications:
|
||||
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
|
||||
case .jumpToUnread:
|
||||
|
|
@ -114,6 +122,8 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true)
|
||||
case .prevSidebarTab:
|
||||
return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true)
|
||||
case .renameTab:
|
||||
return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false)
|
||||
case .renameWorkspace:
|
||||
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
|
||||
case .closeWorkspace:
|
||||
|
|
|
|||
|
|
@ -182,11 +182,11 @@ private struct NotificationRow: View {
|
|||
Button(action: onOpen) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Circle()
|
||||
.fill(notification.isRead ? Color.clear : Color.accentColor)
|
||||
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
||||
.stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
||||
)
|
||||
.padding(.top, 6)
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings {
|
|||
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
|
||||
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
|
||||
|
||||
static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser"
|
||||
static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true
|
||||
|
||||
static let browserHostWhitelistKey = "browserHostWhitelist"
|
||||
static let defaultBrowserHostWhitelist: String = ""
|
||||
|
||||
|
|
@ -137,6 +140,23 @@ enum BrowserLinkOpenSettings {
|
|||
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
|
||||
}
|
||||
|
||||
static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil {
|
||||
return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey)
|
||||
}
|
||||
|
||||
// Migrate existing behavior for users who only had the link-click toggle.
|
||||
if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil {
|
||||
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
|
||||
}
|
||||
|
||||
return defaultInterceptTerminalOpenCommandInCmuxBrowser
|
||||
}
|
||||
|
||||
static func initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: UserDefaults = .standard) -> Bool {
|
||||
interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)
|
||||
}
|
||||
|
||||
static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] {
|
||||
let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist
|
||||
return raw
|
||||
|
|
@ -360,6 +380,21 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest {
|
|||
return preparedRequest
|
||||
}
|
||||
|
||||
private let browserEmbeddedNavigationSchemes: Set<String> = [
|
||||
"about",
|
||||
"applewebdata",
|
||||
"blob",
|
||||
"data",
|
||||
"http",
|
||||
"https",
|
||||
"javascript",
|
||||
]
|
||||
|
||||
func browserShouldOpenURLExternally(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false }
|
||||
return !browserEmbeddedNavigationSchemes.contains(scheme)
|
||||
}
|
||||
|
||||
enum BrowserUserAgentSettings {
|
||||
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
|
||||
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
|
||||
|
|
@ -1151,6 +1186,13 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
/// Published can go forward state
|
||||
@Published private(set) var canGoForward: Bool = false
|
||||
|
||||
private var nativeCanGoBack: Bool = false
|
||||
private var nativeCanGoForward: Bool = false
|
||||
private var usesRestoredSessionHistory: Bool = false
|
||||
private var restoredBackHistoryStack: [URL] = []
|
||||
private var restoredForwardHistoryStack: [URL] = []
|
||||
private var restoredHistoryCurrentURL: URL?
|
||||
|
||||
/// Published estimated progress (0.0 - 1.0)
|
||||
@Published private(set) var estimatedProgress: Double = 0.0
|
||||
|
||||
|
|
@ -1353,6 +1395,43 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
focusFlashToken &+= 1
|
||||
}
|
||||
|
||||
func sessionNavigationHistorySnapshot() -> (
|
||||
backHistoryURLStrings: [String],
|
||||
forwardHistoryURLStrings: [String]
|
||||
) {
|
||||
if usesRestoredSessionHistory {
|
||||
let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) }
|
||||
// `restoredForwardHistoryStack` stores nearest-forward entries at the end.
|
||||
let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) }
|
||||
return (back, forward)
|
||||
}
|
||||
|
||||
let back = webView.backForwardList.backList.compactMap {
|
||||
Self.serializableSessionHistoryURLString($0.url)
|
||||
}
|
||||
let forward = webView.backForwardList.forwardList.compactMap {
|
||||
Self.serializableSessionHistoryURLString($0.url)
|
||||
}
|
||||
return (back, forward)
|
||||
}
|
||||
|
||||
func restoreSessionNavigationHistory(
|
||||
backHistoryURLStrings: [String],
|
||||
forwardHistoryURLStrings: [String],
|
||||
currentURLString: String?
|
||||
) {
|
||||
let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings)
|
||||
let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings)
|
||||
guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return }
|
||||
|
||||
usesRestoredSessionHistory = true
|
||||
restoredBackHistoryStack = restoredBack
|
||||
// Store nearest-forward entries at the end to make stack pop operations trivial.
|
||||
restoredForwardHistoryStack = Array(restoredForward.reversed())
|
||||
restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString)
|
||||
refreshNavigationAvailability()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
// URL changes
|
||||
let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
|
||||
|
|
@ -1386,7 +1465,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
// Can go back
|
||||
let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in
|
||||
Task { @MainActor in
|
||||
self?.canGoBack = webView.canGoBack
|
||||
guard let self else { return }
|
||||
self.nativeCanGoBack = webView.canGoBack
|
||||
self.refreshNavigationAvailability()
|
||||
}
|
||||
}
|
||||
webViewObservers.append(backObserver)
|
||||
|
|
@ -1394,7 +1475,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
// Can go forward
|
||||
let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in
|
||||
Task { @MainActor in
|
||||
self?.canGoForward = webView.canGoForward
|
||||
guard let self else { return }
|
||||
self.nativeCanGoForward = webView.canGoForward
|
||||
self.refreshNavigationAvailability()
|
||||
}
|
||||
}
|
||||
webViewObservers.append(forwardObserver)
|
||||
|
|
@ -1612,6 +1695,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
faviconTask?.cancel()
|
||||
faviconTask = nil
|
||||
lastFaviconURLString = nil
|
||||
// Clear the previous page's favicon so it never persists across navigations.
|
||||
// The loading spinner covers this gap; didFinish will fetch the new favicon.
|
||||
faviconPNGData = nil
|
||||
loadingGeneration &+= 1
|
||||
loadingEndWorkItem?.cancel()
|
||||
loadingEndWorkItem = nil
|
||||
|
|
@ -1657,13 +1743,28 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
|
||||
}
|
||||
|
||||
private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) {
|
||||
private func navigateWithoutInsecureHTTPPrompt(
|
||||
to url: URL,
|
||||
recordTypedNavigation: Bool,
|
||||
preserveRestoredSessionHistory: Bool = false
|
||||
) {
|
||||
let request = URLRequest(url: url)
|
||||
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
|
||||
navigateWithoutInsecureHTTPPrompt(
|
||||
request: request,
|
||||
recordTypedNavigation: recordTypedNavigation,
|
||||
preserveRestoredSessionHistory: preserveRestoredSessionHistory
|
||||
)
|
||||
}
|
||||
|
||||
private func navigateWithoutInsecureHTTPPrompt(request: URLRequest, recordTypedNavigation: Bool) {
|
||||
private func navigateWithoutInsecureHTTPPrompt(
|
||||
request: URLRequest,
|
||||
recordTypedNavigation: Bool,
|
||||
preserveRestoredSessionHistory: Bool = false
|
||||
) {
|
||||
guard let url = request.url else { return }
|
||||
if !preserveRestoredSessionHistory {
|
||||
abandonRestoredSessionHistoryIfNeeded()
|
||||
}
|
||||
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
|
||||
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
||||
shouldRenderWebView = true
|
||||
|
|
@ -1808,26 +1909,90 @@ extension BrowserPanel {
|
|||
/// Go back in history
|
||||
func goBack() {
|
||||
guard canGoBack else { return }
|
||||
if usesRestoredSessionHistory {
|
||||
guard let targetURL = restoredBackHistoryStack.popLast() else {
|
||||
refreshNavigationAvailability()
|
||||
return
|
||||
}
|
||||
if let current = resolvedCurrentSessionHistoryURL() {
|
||||
restoredForwardHistoryStack.append(current)
|
||||
}
|
||||
restoredHistoryCurrentURL = targetURL
|
||||
refreshNavigationAvailability()
|
||||
navigateWithoutInsecureHTTPPrompt(
|
||||
to: targetURL,
|
||||
recordTypedNavigation: false,
|
||||
preserveRestoredSessionHistory: true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
webView.goBack()
|
||||
}
|
||||
|
||||
/// Go forward in history
|
||||
func goForward() {
|
||||
guard canGoForward else { return }
|
||||
if usesRestoredSessionHistory {
|
||||
guard let targetURL = restoredForwardHistoryStack.popLast() else {
|
||||
refreshNavigationAvailability()
|
||||
return
|
||||
}
|
||||
if let current = resolvedCurrentSessionHistoryURL() {
|
||||
restoredBackHistoryStack.append(current)
|
||||
}
|
||||
restoredHistoryCurrentURL = targetURL
|
||||
refreshNavigationAvailability()
|
||||
navigateWithoutInsecureHTTPPrompt(
|
||||
to: targetURL,
|
||||
recordTypedNavigation: false,
|
||||
preserveRestoredSessionHistory: true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
webView.goForward()
|
||||
}
|
||||
|
||||
/// Open a link in a new browser surface in the same pane
|
||||
func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) {
|
||||
guard let tabManager = AppDelegate.shared?.tabManager,
|
||||
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }),
|
||||
let paneId = workspace.paneId(forPanelId: id) else { return }
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " +
|
||||
"workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " +
|
||||
"bypass=\(bypassInsecureHTTPHostOnce ?? "nil")"
|
||||
)
|
||||
#endif
|
||||
guard let tabManager = AppDelegate.shared?.tabManager else {
|
||||
#if DEBUG
|
||||
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingTabManager")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
|
||||
#if DEBUG
|
||||
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
guard let paneId = workspace.paneId(forPanelId: id) else {
|
||||
#if DEBUG
|
||||
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
workspace.newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
|
||||
)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " +
|
||||
"workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Reload the current page
|
||||
|
|
@ -2097,10 +2262,20 @@ extension BrowserPanel {
|
|||
}
|
||||
|
||||
func beginSuppressWebViewFocusForAddressBar() {
|
||||
if !suppressWebViewFocusForAddressBar {
|
||||
#if DEBUG
|
||||
dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))")
|
||||
#endif
|
||||
}
|
||||
suppressWebViewFocusForAddressBar = true
|
||||
}
|
||||
|
||||
func endSuppressWebViewFocusForAddressBar() {
|
||||
if suppressWebViewFocusForAddressBar {
|
||||
#if DEBUG
|
||||
dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))")
|
||||
#endif
|
||||
}
|
||||
suppressWebViewFocusForAddressBar = false
|
||||
}
|
||||
|
||||
|
|
@ -2140,6 +2315,64 @@ extension BrowserPanel {
|
|||
return nil
|
||||
}
|
||||
|
||||
private func resolvedCurrentSessionHistoryURL() -> URL? {
|
||||
if let webViewURL = webView.url,
|
||||
Self.serializableSessionHistoryURLString(webViewURL) != nil {
|
||||
return webViewURL
|
||||
}
|
||||
if let currentURL,
|
||||
Self.serializableSessionHistoryURLString(currentURL) != nil {
|
||||
return currentURL
|
||||
}
|
||||
return restoredHistoryCurrentURL
|
||||
}
|
||||
|
||||
private func refreshNavigationAvailability() {
|
||||
let resolvedCanGoBack: Bool
|
||||
let resolvedCanGoForward: Bool
|
||||
if usesRestoredSessionHistory {
|
||||
resolvedCanGoBack = !restoredBackHistoryStack.isEmpty
|
||||
resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty
|
||||
} else {
|
||||
resolvedCanGoBack = nativeCanGoBack
|
||||
resolvedCanGoForward = nativeCanGoForward
|
||||
}
|
||||
|
||||
if canGoBack != resolvedCanGoBack {
|
||||
canGoBack = resolvedCanGoBack
|
||||
}
|
||||
if canGoForward != resolvedCanGoForward {
|
||||
canGoForward = resolvedCanGoForward
|
||||
}
|
||||
}
|
||||
|
||||
private func abandonRestoredSessionHistoryIfNeeded() {
|
||||
guard usesRestoredSessionHistory else { return }
|
||||
usesRestoredSessionHistory = false
|
||||
restoredBackHistoryStack.removeAll(keepingCapacity: false)
|
||||
restoredForwardHistoryStack.removeAll(keepingCapacity: false)
|
||||
restoredHistoryCurrentURL = nil
|
||||
refreshNavigationAvailability()
|
||||
}
|
||||
|
||||
private static func serializableSessionHistoryURLString(_ url: URL?) -> String? {
|
||||
guard let url else { return nil }
|
||||
let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty, value != "about:blank" else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil }
|
||||
return URL(string: trimmed)
|
||||
}
|
||||
|
||||
private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] {
|
||||
values.compactMap { sanitizedSessionHistoryURL($0) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension BrowserPanel {
|
||||
|
|
@ -2459,6 +2692,39 @@ private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate {
|
|||
|
||||
// MARK: - Navigation Delegate
|
||||
|
||||
func browserNavigationShouldOpenInNewTab(
|
||||
navigationType: WKNavigationType,
|
||||
modifierFlags: NSEvent.ModifierFlags,
|
||||
buttonNumber: Int,
|
||||
hasRecentMiddleClickIntent: Bool = false,
|
||||
currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type,
|
||||
currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber
|
||||
) -> Bool {
|
||||
guard navigationType == .linkActivated || navigationType == .other else {
|
||||
return false
|
||||
}
|
||||
|
||||
if modifierFlags.contains(.command) {
|
||||
return true
|
||||
}
|
||||
if buttonNumber == 2 {
|
||||
return true
|
||||
}
|
||||
// In some WebKit paths, middle-click arrives as buttonNumber=4.
|
||||
// Recover intent when we just observed a local middle-click.
|
||||
if buttonNumber == 4, hasRecentMiddleClickIntent {
|
||||
return true
|
||||
}
|
||||
|
||||
// WebKit can omit buttonNumber for middle-click link activations.
|
||||
if let currentEventType,
|
||||
(currentEventType == .otherMouseDown || currentEventType == .otherMouseUp),
|
||||
currentEventButtonNumber == 2 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
var didFinish: ((WKWebView) -> Void)?
|
||||
var didFailNavigation: ((WKWebView, String) -> Void)?
|
||||
|
|
@ -2481,6 +2747,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
NSLog("BrowserPanel navigation failed: %@", error.localizedDescription)
|
||||
// Treat committed-navigation failures the same as provisional ones so
|
||||
// stale favicon/title state from the prior page gets cleared.
|
||||
let failedURL = webView.url?.absoluteString ?? ""
|
||||
didFailNavigation?(webView, failedURL)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
|
|
@ -2593,38 +2863,89 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView)
|
||||
let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab(
|
||||
navigationType: navigationAction.navigationType,
|
||||
modifierFlags: navigationAction.modifierFlags,
|
||||
buttonNumber: navigationAction.buttonNumber,
|
||||
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent
|
||||
)
|
||||
#if DEBUG
|
||||
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
||||
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
|
||||
let navType = String(describing: navigationAction.navigationType)
|
||||
dlog(
|
||||
"browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " +
|
||||
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
|
||||
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
|
||||
"recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " +
|
||||
"openInNewTab=\(shouldOpenInNewTab ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
|
||||
if let url = navigationAction.request.url,
|
||||
navigationAction.targetFrame?.isMainFrame != false,
|
||||
shouldBlockInsecureHTTPNavigation?(url) == true {
|
||||
let intent: BrowserInsecureHTTPNavigationIntent
|
||||
if navigationAction.navigationType == .linkActivated,
|
||||
navigationAction.modifierFlags.contains(.command) {
|
||||
if shouldOpenInNewTab {
|
||||
intent = .newTab
|
||||
} else {
|
||||
intent = .currentTab
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " +
|
||||
"url=\(url.absoluteString)"
|
||||
)
|
||||
#endif
|
||||
handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// target=_blank or window.open() — navigate in the current webview
|
||||
if navigationAction.targetFrame == nil,
|
||||
navigationAction.request.url != nil {
|
||||
webView.load(navigationAction.request)
|
||||
// WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.).
|
||||
// Hand these off to macOS so the owning app can handle them.
|
||||
if let url = navigationAction.request.url,
|
||||
navigationAction.targetFrame?.isMainFrame != false,
|
||||
browserShouldOpenURLExternally(url) {
|
||||
let opened = NSWorkspace.shared.open(url)
|
||||
if !opened {
|
||||
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
|
||||
#endif
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd+click on a regular link — open in a new tab
|
||||
if navigationAction.navigationType == .linkActivated,
|
||||
navigationAction.modifierFlags.contains(.command),
|
||||
// Cmd+click and middle-click on regular links should always open in a new tab.
|
||||
if shouldOpenInNewTab,
|
||||
let url = navigationAction.request.url {
|
||||
#if DEBUG
|
||||
dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)")
|
||||
#endif
|
||||
openInNewTab?(url)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// target=_blank or window.open() without explicit new-tab intent — navigate in-place.
|
||||
if navigationAction.targetFrame == nil,
|
||||
navigationAction.request.url != nil {
|
||||
#if DEBUG
|
||||
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
|
||||
dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)")
|
||||
#endif
|
||||
webView.load(navigationAction.request)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
|
||||
dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)")
|
||||
#endif
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
|
|
@ -2723,21 +3044,62 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
|
|||
}
|
||||
|
||||
/// Returning nil tells WebKit not to open a new window.
|
||||
/// Cmd+click opens in a new tab; regular target=_blank navigates in-place.
|
||||
/// Cmd+click and middle-click open in a new tab; regular target=_blank navigates in-place.
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for navigationAction: WKNavigationAction,
|
||||
windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView)
|
||||
let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab(
|
||||
navigationType: navigationAction.navigationType,
|
||||
modifierFlags: navigationAction.modifierFlags,
|
||||
buttonNumber: navigationAction.buttonNumber,
|
||||
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent
|
||||
)
|
||||
#if DEBUG
|
||||
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
||||
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
|
||||
let navType = String(describing: navigationAction.navigationType)
|
||||
dlog(
|
||||
"browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " +
|
||||
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
|
||||
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
|
||||
"recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " +
|
||||
"openInNewTab=\(shouldOpenInNewTab ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
if let url = navigationAction.request.url {
|
||||
if browserShouldOpenURLExternally(url) {
|
||||
let opened = NSWorkspace.shared.open(url)
|
||||
if !opened {
|
||||
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
if let requestNavigation {
|
||||
let intent: BrowserInsecureHTTPNavigationIntent =
|
||||
navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab
|
||||
shouldOpenInNewTab ? .newTab : .currentTab
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.nav.createWebView.action kind=requestNavigation intent=\(intent == .newTab ? "newTab" : "currentTab") " +
|
||||
"url=\(url.absoluteString)"
|
||||
)
|
||||
#endif
|
||||
requestNavigation(navigationAction.request, intent)
|
||||
} else if navigationAction.modifierFlags.contains(.command) {
|
||||
} else if shouldOpenInNewTab {
|
||||
#if DEBUG
|
||||
dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)")
|
||||
#endif
|
||||
openInNewTab?(url)
|
||||
} else {
|
||||
#if DEBUG
|
||||
dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)")
|
||||
#endif
|
||||
webView.load(navigationAction.request)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable {
|
|||
// Matches Bonsplit tab icon tint for active tabs.
|
||||
return Color(nsColor: .labelColor)
|
||||
case .accent:
|
||||
return .accentColor
|
||||
return cmuxAccentColor()
|
||||
case .tertiary:
|
||||
return Color(nsColor: .tertiaryLabelColor)
|
||||
}
|
||||
|
|
@ -163,6 +163,46 @@ private extension View {
|
|||
}
|
||||
}
|
||||
|
||||
func resolvedBrowserChromeBackgroundColor(
|
||||
for colorScheme: ColorScheme,
|
||||
themeBackgroundColor: NSColor
|
||||
) -> NSColor {
|
||||
switch colorScheme {
|
||||
case .dark, .light:
|
||||
return themeBackgroundColor
|
||||
@unknown default:
|
||||
return themeBackgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedBrowserChromeColorScheme(
|
||||
for colorScheme: ColorScheme,
|
||||
themeBackgroundColor: NSColor
|
||||
) -> ColorScheme {
|
||||
let backgroundColor = resolvedBrowserChromeBackgroundColor(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: themeBackgroundColor
|
||||
)
|
||||
return backgroundColor.isLightColor ? .light : .dark
|
||||
}
|
||||
|
||||
func resolvedBrowserOmnibarPillBackgroundColor(
|
||||
for colorScheme: ColorScheme,
|
||||
themeBackgroundColor: NSColor
|
||||
) -> NSColor {
|
||||
let darkenMix: CGFloat
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
darkenMix = 0.04
|
||||
case .dark:
|
||||
darkenMix = 0.05
|
||||
@unknown default:
|
||||
darkenMix = 0.04
|
||||
}
|
||||
|
||||
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
|
||||
}
|
||||
|
||||
/// View for rendering a browser panel with address bar
|
||||
struct BrowserPanelView: View {
|
||||
@ObservedObject var panel: BrowserPanel
|
||||
|
|
@ -187,7 +227,7 @@ struct BrowserPanelView: View {
|
|||
@State private var omnibarHasMarkedText: Bool = false
|
||||
@State private var suppressNextFocusLostRevert: Bool = false
|
||||
@State private var focusFlashOpacity: Double = 0.0
|
||||
@State private var focusFlashFadeWorkItem: DispatchWorkItem?
|
||||
@State private var focusFlashAnimationGeneration: Int = 0
|
||||
@State private var omnibarPillFrame: CGRect = .zero
|
||||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
@State private var isBrowserThemeMenuPresented = false
|
||||
|
|
@ -236,14 +276,24 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private var browserChromeBackgroundColor: NSColor {
|
||||
switch colorScheme {
|
||||
case .dark:
|
||||
return GhosttyApp.shared.defaultBackgroundColor
|
||||
case .light:
|
||||
return .windowBackgroundColor
|
||||
@unknown default:
|
||||
return .windowBackgroundColor
|
||||
}
|
||||
resolvedBrowserChromeBackgroundColor(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
private var browserChromeColorScheme: ColorScheme {
|
||||
resolvedBrowserChromeColorScheme(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
private var omnibarPillBackgroundColor: NSColor {
|
||||
resolvedBrowserOmnibarPillBackgroundColor(
|
||||
for: browserChromeColorScheme,
|
||||
themeBackgroundColor: browserChromeBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -252,10 +302,10 @@ struct BrowserPanelView: View {
|
|||
webView
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3)
|
||||
.shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10)
|
||||
.padding(6)
|
||||
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
|
||||
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
|
||||
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
|
||||
.padding(FocusFlashPattern.ringInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
|
|
@ -275,8 +325,9 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
)
|
||||
.frame(width: omnibarPillFrame.width)
|
||||
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6)
|
||||
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3)
|
||||
.zIndex(1000)
|
||||
.environment(\.colorScheme, browserChromeColorScheme)
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "BrowserPanelViewSpace")
|
||||
|
|
@ -288,16 +339,15 @@ struct BrowserPanelView: View {
|
|||
guard let webView = note.object as? CmuxWebView else { return false }
|
||||
return webView === panel?.webView
|
||||
}) { _ in
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"isFocused=\(isFocused ? 1 : 0) " +
|
||||
"addressFocused=\(addressBarFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
onRequestPanelFocus()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .webViewMiddleClickedLink).filter { [weak panel] note in
|
||||
guard let webView = note.object as? CmuxWebView else { return false }
|
||||
return webView === panel?.webView
|
||||
}) { note in
|
||||
if let url = note.userInfo?["url"] as? URL {
|
||||
panel.openLinkInNewTab(url: url)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
|
||||
|
|
@ -314,6 +364,7 @@ struct BrowserPanelView: View {
|
|||
syncURLFromPanel()
|
||||
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
|
||||
autoFocusOmnibarIfBlank()
|
||||
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
|
||||
BrowserHistoryStore.shared.loadIfNeeded()
|
||||
}
|
||||
.onChange(of: panel.focusFlashToken) { _ in
|
||||
|
|
@ -353,6 +404,7 @@ struct BrowserPanelView: View {
|
|||
hideSuggestions()
|
||||
addressBarFocused = false
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
|
||||
}
|
||||
.onChange(of: addressBarFocused) { focused in
|
||||
let urlString = panel.preferredURLStringForOmnibar() ?? ""
|
||||
|
|
@ -380,6 +432,7 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
inlineCompletion = nil
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
|
||||
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
|
||||
|
|
@ -421,6 +474,7 @@ struct BrowserPanelView: View {
|
|||
.background(Color(nsColor: browserChromeBackgroundColor))
|
||||
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
|
||||
.zIndex(1)
|
||||
.environment(\.colorScheme, browserChromeColorScheme)
|
||||
}
|
||||
|
||||
private var addressBarButtonBar: some View {
|
||||
|
|
@ -635,11 +689,11 @@ struct BrowserPanelView: View {
|
|||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
||||
.fill(Color(nsColor: .textBackgroundColor))
|
||||
.fill(Color(nsColor: omnibarPillBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
||||
.stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1)
|
||||
.stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.accessibilityElement(children: .contain)
|
||||
.background {
|
||||
|
|
@ -689,20 +743,42 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private func triggerFocusFlashAnimation() {
|
||||
focusFlashFadeWorkItem?.cancel()
|
||||
focusFlashFadeWorkItem = nil
|
||||
focusFlashAnimationGeneration &+= 1
|
||||
let generation = focusFlashAnimationGeneration
|
||||
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
|
||||
|
||||
withAnimation(.easeOut(duration: 0.08)) {
|
||||
focusFlashOpacity = 1.0
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem {
|
||||
withAnimation(.easeOut(duration: 0.35)) {
|
||||
focusFlashOpacity = 0.0
|
||||
for segment in FocusFlashPattern.segments {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
|
||||
guard focusFlashAnimationGeneration == generation else { return }
|
||||
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
|
||||
focusFlashOpacity = segment.targetOpacity
|
||||
}
|
||||
}
|
||||
}
|
||||
focusFlashFadeWorkItem = item
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: item)
|
||||
}
|
||||
|
||||
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
|
||||
switch curve {
|
||||
case .easeIn:
|
||||
return .easeIn(duration: duration)
|
||||
case .easeOut:
|
||||
return .easeOut(duration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncWebViewResponderPolicyWithViewState(reason: String) {
|
||||
guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
|
||||
let next = isFocused && !panel.shouldSuppressWebViewFocus()
|
||||
if cmuxWebView.allowsFirstResponderAcquisition != next {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"new=\(next ? 1 : 0) reason=\(reason)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
cmuxWebView.allowsFirstResponderAcquisition = next
|
||||
}
|
||||
|
||||
private func syncURLFromPanel() {
|
||||
|
|
@ -711,8 +787,32 @@ struct BrowserPanelView: View {
|
|||
applyOmnibarEffects(effects)
|
||||
}
|
||||
|
||||
private func isCommandPaletteVisibleForPanelWindow() -> Bool {
|
||||
guard let app = AppDelegate.shared else { return false }
|
||||
|
||||
if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let manager = app.tabManagerFor(tabId: panel.workspaceId),
|
||||
let windowId = app.windowId(for: manager),
|
||||
let window = app.mainWindow(for: windowId),
|
||||
app.isCommandPaletteVisible(for: window) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
|
||||
return true
|
||||
}
|
||||
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func applyPendingAddressBarFocusRequestIfNeeded() {
|
||||
guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else { return }
|
||||
guard lastHandledAddressBarFocusRequestId != requestId else { return }
|
||||
lastHandledAddressBarFocusRequestId = requestId
|
||||
panel.beginSuppressWebViewFocusForAddressBar()
|
||||
|
|
@ -740,6 +840,7 @@ struct BrowserPanelView: View {
|
|||
private func autoFocusOmnibarIfBlank() {
|
||||
guard isFocused else { return }
|
||||
guard !addressBarFocused else { return }
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else { return }
|
||||
// If a test/automation explicitly focused WebKit, don't steal focus back.
|
||||
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
|
||||
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
|
||||
|
|
@ -2114,6 +2215,13 @@ struct OmnibarSuggestion: Identifiable, Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
func browserOmnibarShouldReacquireFocusAfterEndEditing(
|
||||
suppressWebViewFocus: Bool,
|
||||
nextResponderIsOtherTextField: Bool
|
||||
) -> Bool {
|
||||
suppressWebViewFocus && !nextResponderIsOtherTextField
|
||||
}
|
||||
|
||||
private final class OmnibarNativeTextField: NSTextField {
|
||||
var onPointerDown: (() -> Void)?
|
||||
var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)?
|
||||
|
|
@ -2226,6 +2334,29 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool {
|
||||
guard let window, let field = parentField else { return false }
|
||||
let responder = window.firstResponder
|
||||
|
||||
if let editor = responder as? NSTextView,
|
||||
let delegateField = editor.delegate as? NSTextField {
|
||||
return delegateField !== field
|
||||
}
|
||||
|
||||
if let textField = responder as? NSTextField {
|
||||
return textField !== field
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
|
||||
return browserOmnibarShouldReacquireFocusAfterEndEditing(
|
||||
suppressWebViewFocus: parent.shouldSuppressWebViewFocus(),
|
||||
nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
|
||||
)
|
||||
}
|
||||
|
||||
func controlTextDidBeginEditing(_ obj: Notification) {
|
||||
if !parent.isFocused {
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -2238,15 +2369,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
|
||||
func controlTextDidEndEditing(_ obj: Notification) {
|
||||
if parent.isFocused {
|
||||
if parent.shouldSuppressWebViewFocus() {
|
||||
if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
|
||||
guard pendingFocusRequest != true else { return }
|
||||
pendingFocusRequest = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingFocusRequest = nil
|
||||
guard self.parent.isFocused else { return }
|
||||
guard self.parent.shouldSuppressWebViewFocus() else { return }
|
||||
guard let field = self.parentField, let window = field.window else { return }
|
||||
guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
|
||||
self.parent.onFieldLostFocus()
|
||||
return
|
||||
}
|
||||
// Check both the field itself AND its field editor (which becomes
|
||||
// the actual first responder when the text field is being edited).
|
||||
let fr = window.firstResponder
|
||||
|
|
@ -2559,11 +2693,12 @@ private struct OmnibarSuggestionsView: View {
|
|||
let searchSuggestionsEnabled: Bool
|
||||
let onCommit: (OmnibarSuggestion) -> Void
|
||||
let onHighlight: (Int) -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
// Keep radii below the smallest rendered heights so corners don't get
|
||||
// auto-clamped and visually change as popup height changes.
|
||||
private let popupCornerRadius: CGFloat = 16
|
||||
private let rowHighlightCornerRadius: CGFloat = 12
|
||||
// Keep radii below half of the smallest rendered heights so this keeps a
|
||||
// squircle silhouette instead of auto-clamping into a capsule.
|
||||
private let popupCornerRadius: CGFloat = 12
|
||||
private let rowHighlightCornerRadius: CGFloat = 9
|
||||
private let singleLineRowHeight: CGFloat = 24
|
||||
private let rowSpacing: CGFloat = 1
|
||||
private let topInset: CGFloat = 3
|
||||
|
|
@ -2616,6 +2751,101 @@ private struct OmnibarSuggestionsView: View {
|
|||
contentHeight > maxPopupHeight
|
||||
}
|
||||
|
||||
private var listTextColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color(nsColor: .labelColor)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.9)
|
||||
@unknown default:
|
||||
return Color(nsColor: .labelColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeTextColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.72)
|
||||
@unknown default:
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeBackgroundColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.06)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.08)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
private var rowHighlightColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.07)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.12)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.07)
|
||||
}
|
||||
}
|
||||
|
||||
private var popupOverlayGradientColors: [Color] {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return [
|
||||
Color.white.opacity(0.55),
|
||||
Color.white.opacity(0.2),
|
||||
]
|
||||
case .dark:
|
||||
return [
|
||||
Color.black.opacity(0.26),
|
||||
Color.black.opacity(0.14),
|
||||
]
|
||||
@unknown default:
|
||||
return [
|
||||
Color.white.opacity(0.55),
|
||||
Color.white.opacity(0.2),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private var popupBorderGradientColors: [Color] {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return [
|
||||
Color.white.opacity(0.65),
|
||||
Color.black.opacity(0.12),
|
||||
]
|
||||
case .dark:
|
||||
return [
|
||||
Color.white.opacity(0.22),
|
||||
Color.white.opacity(0.06),
|
||||
]
|
||||
@unknown default:
|
||||
return [
|
||||
Color.white.opacity(0.65),
|
||||
Color.black.opacity(0.12),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private var popupShadowColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.18)
|
||||
case .dark:
|
||||
return Color.black.opacity(0.45)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.18)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rowsView: some View {
|
||||
VStack(spacing: rowSpacing) {
|
||||
|
|
@ -2629,18 +2859,18 @@ private struct OmnibarSuggestionsView: View {
|
|||
HStack(spacing: 6) {
|
||||
Text(item.listText)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color.white.opacity(0.9))
|
||||
.foregroundStyle(listTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if let badge = item.trailingBadgeText {
|
||||
Text(badge)
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(Color.white.opacity(0.72))
|
||||
.foregroundStyle(badgeTextColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.fill(badgeBackgroundColor)
|
||||
)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
|
@ -2656,7 +2886,7 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
|
||||
.fill(
|
||||
idx == selectedIndex
|
||||
? Color.white.opacity(0.12)
|
||||
? rowHighlightColor
|
||||
: Color.clear
|
||||
)
|
||||
)
|
||||
|
|
@ -2711,10 +2941,7 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.26),
|
||||
Color.black.opacity(0.14),
|
||||
],
|
||||
colors: popupOverlayGradientColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
|
@ -2725,18 +2952,16 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.22),
|
||||
Color.white.opacity(0.06),
|
||||
],
|
||||
colors: popupBorderGradientColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 20, y: 10)
|
||||
.contentShape(Rectangle())
|
||||
.clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
|
||||
.shadow(color: popupShadowColor, radius: 20, y: 10)
|
||||
.contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityRespondsToUserInteraction(true)
|
||||
.accessibilityIdentifier("BrowserOmnibarSuggestions")
|
||||
|
|
@ -3035,6 +3260,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator: Coordinator,
|
||||
generation: Int
|
||||
) {
|
||||
let retryInterval: TimeInterval = 1.0 / 60.0
|
||||
// Don't schedule multiple overlapping retries.
|
||||
guard coordinator.attachRetryWorkItem == nil else { return }
|
||||
|
||||
|
|
@ -3067,7 +3293,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// Be generous here: bonsplit structural updates can keep a representable
|
||||
// container off-window longer than a few seconds under load.
|
||||
if coordinator.attachRetryCount < 400 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
|
||||
scheduleAttachRetry(
|
||||
webView,
|
||||
panel: panel,
|
||||
|
|
@ -3104,13 +3330,18 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
coordinator.attachRetryWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
let webView = panel.webView
|
||||
context.coordinator.panel = panel
|
||||
context.coordinator.webView = webView
|
||||
Self.applyWebViewFirstResponderPolicy(
|
||||
panel: panel,
|
||||
webView: webView,
|
||||
isPanelFocused: isPanelFocused
|
||||
)
|
||||
|
||||
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
|
||||
if shouldUseWindowPortal {
|
||||
|
|
@ -3358,6 +3589,26 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private static func applyWebViewFirstResponderPolicy(
|
||||
panel: BrowserPanel,
|
||||
webView: WKWebView,
|
||||
isPanelFocused: Bool
|
||||
) {
|
||||
guard let cmuxWebView = webView as? CmuxWebView else { return }
|
||||
let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
|
||||
if cmuxWebView.allowsFirstResponderAcquisition != next {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " +
|
||||
"suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
cmuxWebView.allowsFirstResponderAcquisition = next
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||||
coordinator.attachRetryWorkItem?.cancel()
|
||||
coordinator.attachRetryWorkItem = nil
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import ObjectiveC
|
||||
import WebKit
|
||||
|
||||
|
|
@ -7,6 +8,37 @@ import WebKit
|
|||
/// key equivalents first so app-level shortcuts continue to work when WebKit is
|
||||
/// the first responder.
|
||||
final class CmuxWebView: WKWebView {
|
||||
// Some sites/WebKit paths report middle-click link activations as
|
||||
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
|
||||
// middle-click so navigation delegates can recover intent reliably.
|
||||
private struct MiddleClickIntent {
|
||||
let webViewID: ObjectIdentifier
|
||||
let uptime: TimeInterval
|
||||
}
|
||||
|
||||
private static var lastMiddleClickIntent: MiddleClickIntent?
|
||||
private static let middleClickIntentMaxAge: TimeInterval = 0.8
|
||||
|
||||
static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool {
|
||||
guard let webView = webView as? CmuxWebView else { return false }
|
||||
guard let intent = lastMiddleClickIntent else { return false }
|
||||
|
||||
let age = ProcessInfo.processInfo.systemUptime - intent.uptime
|
||||
if age > middleClickIntentMaxAge {
|
||||
lastMiddleClickIntent = nil
|
||||
return false
|
||||
}
|
||||
|
||||
return intent.webViewID == ObjectIdentifier(webView)
|
||||
}
|
||||
|
||||
private static func recordMiddleClickIntent(for webView: CmuxWebView) {
|
||||
lastMiddleClickIntent = MiddleClickIntent(
|
||||
webViewID: ObjectIdentifier(webView),
|
||||
uptime: ProcessInfo.processInfo.systemUptime
|
||||
)
|
||||
}
|
||||
|
||||
private final class ContextMenuFallbackBox: NSObject {
|
||||
weak var target: AnyObject?
|
||||
let action: Selector?
|
||||
|
|
@ -22,15 +54,78 @@ final class CmuxWebView: WKWebView {
|
|||
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
|
||||
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
|
||||
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
|
||||
/// Guard against background panes stealing first responder (e.g. page autofocus).
|
||||
/// BrowserPanelView updates this as pane focus state changes.
|
||||
var allowsFirstResponderAcquisition: Bool = true
|
||||
private var pointerFocusAllowanceDepth: Int = 0
|
||||
var allowsFirstResponderAcquisitionEffective: Bool {
|
||||
allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0
|
||||
}
|
||||
var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth }
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
guard allowsFirstResponderAcquisitionEffective else {
|
||||
#if DEBUG
|
||||
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
||||
dlog(
|
||||
"browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " +
|
||||
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
let result = super.becomeFirstResponder()
|
||||
if result {
|
||||
NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self)
|
||||
}
|
||||
#if DEBUG
|
||||
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
||||
dlog(
|
||||
"browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " +
|
||||
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
|
||||
)
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
|
||||
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
|
||||
/// (mouse click into this webview) while keeping background autofocus blocked.
|
||||
func withPointerFocusAllowance(_ body: () -> Void) {
|
||||
pointerFocusAllowanceDepth += 1
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " +
|
||||
"depth=\(pointerFocusAllowanceDepth)"
|
||||
)
|
||||
#endif
|
||||
defer {
|
||||
pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " +
|
||||
"depth=\(pointerFocusAllowanceDepth)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
body()
|
||||
}
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
|
||||
// route it through app/menu key equivalents, which can trigger unintended actions.
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
|
||||
if event.keyCode == 36 || event.keyCode == 76 {
|
||||
// Always bypass app/menu key-equivalent routing for Return/Enter so WebKit
|
||||
// receives the keyDown path used by form submission handlers.
|
||||
return false
|
||||
}
|
||||
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
// Menu/app shortcut routing is only needed for Command equivalents
|
||||
// (New Tab, Close Tab, tab switching, split commands, etc).
|
||||
guard flags.contains(.command) else {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
|
||||
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
return true
|
||||
|
|
@ -63,20 +158,48 @@ final class CmuxWebView: WKWebView {
|
|||
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
|
||||
// bonsplit focus tracks which pane the user clicked in.
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let windowNumber = window?.windowNumber ?? -1
|
||||
let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
dlog(
|
||||
"browser.focus.mouseDown web=\(ObjectIdentifier(self)) " +
|
||||
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)"
|
||||
)
|
||||
#endif
|
||||
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
|
||||
super.mouseDown(with: event)
|
||||
withPointerFocusAllowance {
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mouse back/forward buttons & middle-click
|
||||
// MARK: - Mouse back/forward buttons
|
||||
|
||||
override func otherMouseDown(with event: NSEvent) {
|
||||
if event.buttonNumber == 2 {
|
||||
Self.recordMiddleClickIntent(for: self)
|
||||
}
|
||||
#if DEBUG
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
|
||||
dlog(
|
||||
"browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
|
||||
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
|
||||
)
|
||||
#endif
|
||||
// Button 3 = back, button 4 = forward (multi-button mice like Logitech).
|
||||
// Consume the event so WebKit doesn't handle it.
|
||||
switch event.buttonNumber {
|
||||
case 3:
|
||||
#if DEBUG
|
||||
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)")
|
||||
#endif
|
||||
goBack()
|
||||
return
|
||||
case 4:
|
||||
#if DEBUG
|
||||
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)")
|
||||
#endif
|
||||
goForward()
|
||||
return
|
||||
default:
|
||||
|
|
@ -86,25 +209,23 @@ final class CmuxWebView: WKWebView {
|
|||
}
|
||||
|
||||
override func otherMouseUp(with event: NSEvent) {
|
||||
// Middle-click (button 2) on a link opens it in a new tab.
|
||||
if event.buttonNumber == 2 {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
findLinkAtPoint(point) { [weak self] url in
|
||||
guard let self, let url else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: .webViewMiddleClickedLink,
|
||||
object: self,
|
||||
userInfo: ["url": url]
|
||||
)
|
||||
}
|
||||
return
|
||||
Self.recordMiddleClickIntent(for: self)
|
||||
}
|
||||
#if DEBUG
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
|
||||
dlog(
|
||||
"browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
|
||||
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
|
||||
)
|
||||
#endif
|
||||
super.otherMouseUp(with: event)
|
||||
}
|
||||
|
||||
/// Use JavaScript to find the nearest anchor element at the given view-local point.
|
||||
/// Finds the nearest anchor element at a given view-local point.
|
||||
/// Used as a context-menu download fallback.
|
||||
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
|
||||
// WKWebView's coordinate system is flipped (origin top-left for web content).
|
||||
let flippedY = bounds.height - point.y
|
||||
let js = """
|
||||
(() => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,41 @@ public enum PanelType: String, Codable, Sendable {
|
|||
case browser
|
||||
}
|
||||
|
||||
enum FocusFlashCurve: Equatable {
|
||||
case easeIn
|
||||
case easeOut
|
||||
}
|
||||
|
||||
struct FocusFlashSegment: Equatable {
|
||||
let delay: TimeInterval
|
||||
let duration: TimeInterval
|
||||
let targetOpacity: Double
|
||||
let curve: FocusFlashCurve
|
||||
}
|
||||
|
||||
enum FocusFlashPattern {
|
||||
static let values: [Double] = [0, 1, 0, 1, 0]
|
||||
static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1]
|
||||
static let duration: TimeInterval = 0.9
|
||||
static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn]
|
||||
static let ringInset: Double = 6
|
||||
static let ringCornerRadius: Double = 10
|
||||
|
||||
static var segments: [FocusFlashSegment] {
|
||||
let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1)
|
||||
return (0..<stepCount).map { index in
|
||||
let startTime = keyTimes[index]
|
||||
let endTime = keyTimes[index + 1]
|
||||
return FocusFlashSegment(
|
||||
delay: startTime * duration,
|
||||
duration: (endTime - startTime) * duration,
|
||||
targetOpacity: values[index + 1],
|
||||
curve: curves[index]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol for all panel types (terminal, browser, etc.)
|
||||
@MainActor
|
||||
public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID {
|
||||
|
|
@ -33,6 +68,9 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
|
|||
|
||||
/// Unfocus the panel
|
||||
func unfocus()
|
||||
|
||||
/// Trigger a focus flash animation for this panel.
|
||||
func triggerFlash()
|
||||
}
|
||||
|
||||
/// Extension providing default implementations
|
||||
|
|
|
|||
|
|
@ -83,13 +83,15 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: ghostty_surface_config_s? = nil,
|
||||
workingDirectory: String? = nil,
|
||||
additionalEnvironment: [String: String] = [:],
|
||||
portOrdinal: Int = 0
|
||||
) {
|
||||
let surface = TerminalSurface(
|
||||
tabId: workspaceId,
|
||||
context: context,
|
||||
configTemplate: configTemplate,
|
||||
workingDirectory: workingDirectory
|
||||
workingDirectory: workingDirectory,
|
||||
additionalEnvironment: additionalEnvironment
|
||||
)
|
||||
surface.portOrdinal = portOrdinal
|
||||
self.init(workspaceId: workspaceId, surface: surface)
|
||||
|
|
|
|||
9
Sources/SentryHelper.swift
Normal file
9
Sources/SentryHelper.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import Sentry
|
||||
|
||||
/// Add a Sentry breadcrumb for user-action context in hang/crash reports.
|
||||
func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) {
|
||||
let crumb = Breadcrumb(level: .info, category: category)
|
||||
crumb.message = message
|
||||
crumb.data = data
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
}
|
||||
474
Sources/SessionPersistence.swift
Normal file
474
Sources/SessionPersistence.swift
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import CoreGraphics
|
||||
import Foundation
|
||||
import Bonsplit
|
||||
|
||||
enum SessionSnapshotSchema {
|
||||
static let currentVersion = 1
|
||||
}
|
||||
|
||||
enum SessionPersistencePolicy {
|
||||
static let defaultSidebarWidth: Double = 200
|
||||
static let minimumSidebarWidth: Double = 186
|
||||
static let maximumSidebarWidth: Double = 600
|
||||
static let minimumWindowWidth: Double = 300
|
||||
static let minimumWindowHeight: Double = 200
|
||||
static let autosaveInterval: TimeInterval = 8.0
|
||||
static let maxWindowsPerSnapshot: Int = 12
|
||||
static let maxWorkspacesPerWindow: Int = 128
|
||||
static let maxPanelsPerWorkspace: Int = 512
|
||||
static let maxScrollbackLinesPerTerminal: Int = 4000
|
||||
static let maxScrollbackCharactersPerTerminal: Int = 400_000
|
||||
|
||||
static func sanitizedSidebarWidth(_ candidate: Double?) -> Double {
|
||||
let fallback = defaultSidebarWidth
|
||||
guard let candidate, candidate.isFinite else { return fallback }
|
||||
return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth)
|
||||
}
|
||||
|
||||
static func truncatedScrollback(_ text: String?) -> String? {
|
||||
guard let text, !text.isEmpty else { return nil }
|
||||
if text.count <= maxScrollbackCharactersPerTerminal {
|
||||
return text
|
||||
}
|
||||
let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal)
|
||||
let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart)
|
||||
return String(text[safeStart...])
|
||||
}
|
||||
|
||||
/// If truncation starts in the middle of an ANSI CSI escape sequence, advance
|
||||
/// to the first printable character after that sequence to avoid replaying
|
||||
/// malformed control bytes.
|
||||
private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index {
|
||||
guard initialStart > text.startIndex else { return initialStart }
|
||||
let escape = "\u{001B}"
|
||||
|
||||
guard let lastEscape = text[..<initialStart].lastIndex(of: Character(escape)) else {
|
||||
return initialStart
|
||||
}
|
||||
let csiMarker = text.index(after: lastEscape)
|
||||
guard csiMarker < text.endIndex, text[csiMarker] == "[" else {
|
||||
return initialStart
|
||||
}
|
||||
|
||||
// If a final CSI byte exists before the truncation boundary, we are not
|
||||
// inside a partial sequence.
|
||||
if csiFinalByteIndex(in: text, from: csiMarker, upperBound: initialStart) != nil {
|
||||
return initialStart
|
||||
}
|
||||
|
||||
// We are inside a CSI sequence. Skip to the first character after the
|
||||
// sequence terminator if it exists.
|
||||
guard let final = csiFinalByteIndex(in: text, from: csiMarker, upperBound: text.endIndex) else {
|
||||
return initialStart
|
||||
}
|
||||
let next = text.index(after: final)
|
||||
return next < text.endIndex ? next : text.endIndex
|
||||
}
|
||||
|
||||
private static func csiFinalByteIndex(
|
||||
in text: String,
|
||||
from csiMarker: String.Index,
|
||||
upperBound: String.Index
|
||||
) -> String.Index? {
|
||||
var index = text.index(after: csiMarker)
|
||||
while index < upperBound {
|
||||
guard let scalar = text[index].unicodeScalars.first?.value else {
|
||||
index = text.index(after: index)
|
||||
continue
|
||||
}
|
||||
if scalar >= 0x40, scalar <= 0x7E {
|
||||
return index
|
||||
}
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionRestorePolicy {
|
||||
static func isRunningUnderAutomatedTests(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
if environment["CMUX_UI_TEST_MODE"] == "1" {
|
||||
return true
|
||||
}
|
||||
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
|
||||
return true
|
||||
}
|
||||
if environment["XCTestConfigurationFilePath"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["XCTestBundlePath"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["XCTestSessionIdentifier"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["XCInjectBundle"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["XCInjectBundleInto"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func shouldAttemptRestore(
|
||||
arguments: [String] = CommandLine.arguments,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" {
|
||||
return false
|
||||
}
|
||||
if isRunningUnderAutomatedTests(environment: environment) {
|
||||
return false
|
||||
}
|
||||
|
||||
let extraArgs = arguments
|
||||
.dropFirst()
|
||||
.filter { !$0.hasPrefix("-psn_") }
|
||||
|
||||
// Any explicit launch argument is treated as an explicit open intent.
|
||||
return extraArgs.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionRectSnapshot: Codable, Equatable, Sendable {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let width: Double
|
||||
let height: Double
|
||||
|
||||
init(x: Double, y: Double, width: Double, height: Double) {
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
init(_ rect: CGRect) {
|
||||
self.x = Double(rect.origin.x)
|
||||
self.y = Double(rect.origin.y)
|
||||
self.width = Double(rect.size.width)
|
||||
self.height = Double(rect.size.height)
|
||||
}
|
||||
|
||||
var cgRect: CGRect {
|
||||
CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionDisplaySnapshot: Codable, Sendable {
|
||||
var displayID: UInt32?
|
||||
var frame: SessionRectSnapshot?
|
||||
var visibleFrame: SessionRectSnapshot?
|
||||
}
|
||||
|
||||
enum SessionSidebarSelection: String, Codable, Sendable, Equatable {
|
||||
case tabs
|
||||
case notifications
|
||||
|
||||
init(selection: SidebarSelection) {
|
||||
switch selection {
|
||||
case .tabs:
|
||||
self = .tabs
|
||||
case .notifications:
|
||||
self = .notifications
|
||||
}
|
||||
}
|
||||
|
||||
var sidebarSelection: SidebarSelection {
|
||||
switch self {
|
||||
case .tabs:
|
||||
return .tabs
|
||||
case .notifications:
|
||||
return .notifications
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionSidebarSnapshot: Codable, Sendable {
|
||||
var isVisible: Bool
|
||||
var selection: SessionSidebarSelection
|
||||
var width: Double?
|
||||
}
|
||||
|
||||
struct SessionStatusEntrySnapshot: Codable, Sendable {
|
||||
var key: String
|
||||
var value: String
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var timestamp: TimeInterval
|
||||
}
|
||||
|
||||
struct SessionLogEntrySnapshot: Codable, Sendable {
|
||||
var message: String
|
||||
var level: String
|
||||
var source: String?
|
||||
var timestamp: TimeInterval
|
||||
}
|
||||
|
||||
struct SessionProgressSnapshot: Codable, Sendable {
|
||||
var value: Double
|
||||
var label: String?
|
||||
}
|
||||
|
||||
struct SessionGitBranchSnapshot: Codable, Sendable {
|
||||
var branch: String
|
||||
var isDirty: Bool
|
||||
}
|
||||
|
||||
struct SessionTerminalPanelSnapshot: Codable, Sendable {
|
||||
var workingDirectory: String?
|
||||
var scrollback: String?
|
||||
}
|
||||
|
||||
struct SessionBrowserPanelSnapshot: Codable, Sendable {
|
||||
var urlString: String?
|
||||
var shouldRenderWebView: Bool
|
||||
var pageZoom: Double
|
||||
var developerToolsVisible: Bool
|
||||
var backHistoryURLStrings: [String]?
|
||||
var forwardHistoryURLStrings: [String]?
|
||||
}
|
||||
|
||||
struct SessionPanelSnapshot: Codable, Sendable {
|
||||
var id: UUID
|
||||
var type: PanelType
|
||||
var title: String?
|
||||
var customTitle: String?
|
||||
var directory: String?
|
||||
var isPinned: Bool
|
||||
var isManuallyUnread: Bool
|
||||
var gitBranch: SessionGitBranchSnapshot?
|
||||
var listeningPorts: [Int]
|
||||
var ttyName: String?
|
||||
var terminal: SessionTerminalPanelSnapshot?
|
||||
var browser: SessionBrowserPanelSnapshot?
|
||||
}
|
||||
|
||||
enum SessionSplitOrientation: String, Codable, Sendable {
|
||||
case horizontal
|
||||
case vertical
|
||||
|
||||
init(_ orientation: SplitOrientation) {
|
||||
switch orientation {
|
||||
case .horizontal:
|
||||
self = .horizontal
|
||||
case .vertical:
|
||||
self = .vertical
|
||||
}
|
||||
}
|
||||
|
||||
var splitOrientation: SplitOrientation {
|
||||
switch self {
|
||||
case .horizontal:
|
||||
return .horizontal
|
||||
case .vertical:
|
||||
return .vertical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionPaneLayoutSnapshot: Codable, Sendable {
|
||||
var panelIds: [UUID]
|
||||
var selectedPanelId: UUID?
|
||||
}
|
||||
|
||||
struct SessionSplitLayoutSnapshot: Codable, Sendable {
|
||||
var orientation: SessionSplitOrientation
|
||||
var dividerPosition: Double
|
||||
var first: SessionWorkspaceLayoutSnapshot
|
||||
var second: SessionWorkspaceLayoutSnapshot
|
||||
}
|
||||
|
||||
indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable {
|
||||
case pane(SessionPaneLayoutSnapshot)
|
||||
case split(SessionSplitLayoutSnapshot)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case pane
|
||||
case split
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
switch type {
|
||||
case "pane":
|
||||
self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane))
|
||||
case "split":
|
||||
self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)")
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .pane(let pane):
|
||||
try container.encode("pane", forKey: .type)
|
||||
try container.encode(pane, forKey: .pane)
|
||||
case .split(let split):
|
||||
try container.encode("split", forKey: .type)
|
||||
try container.encode(split, forKey: .split)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionWorkspaceSnapshot: Codable, Sendable {
|
||||
var processTitle: String
|
||||
var customTitle: String?
|
||||
var customColor: String?
|
||||
var isPinned: Bool
|
||||
var currentDirectory: String
|
||||
var focusedPanelId: UUID?
|
||||
var layout: SessionWorkspaceLayoutSnapshot
|
||||
var panels: [SessionPanelSnapshot]
|
||||
var statusEntries: [SessionStatusEntrySnapshot]
|
||||
var logEntries: [SessionLogEntrySnapshot]
|
||||
var progress: SessionProgressSnapshot?
|
||||
var gitBranch: SessionGitBranchSnapshot?
|
||||
}
|
||||
|
||||
struct SessionTabManagerSnapshot: Codable, Sendable {
|
||||
var selectedWorkspaceIndex: Int?
|
||||
var workspaces: [SessionWorkspaceSnapshot]
|
||||
}
|
||||
|
||||
struct SessionWindowSnapshot: Codable, Sendable {
|
||||
var frame: SessionRectSnapshot?
|
||||
var display: SessionDisplaySnapshot?
|
||||
var tabManager: SessionTabManagerSnapshot
|
||||
var sidebar: SessionSidebarSnapshot
|
||||
}
|
||||
|
||||
struct AppSessionSnapshot: Codable, Sendable {
|
||||
var version: Int
|
||||
var createdAt: TimeInterval
|
||||
var windows: [SessionWindowSnapshot]
|
||||
}
|
||||
|
||||
enum SessionPersistenceStore {
|
||||
static func load(fileURL: URL? = nil) -> AppSessionSnapshot? {
|
||||
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil }
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
let decoder = JSONDecoder()
|
||||
guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil }
|
||||
guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil }
|
||||
guard !snapshot.windows.isEmpty else { return nil }
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool {
|
||||
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false }
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(snapshot)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func removeSnapshot(fileURL: URL? = nil) {
|
||||
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return }
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
static func defaultSnapshotFileURL(
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
appSupportDirectory: URL? = nil
|
||||
) -> URL? {
|
||||
let resolvedAppSupport: URL
|
||||
if let appSupportDirectory {
|
||||
resolvedAppSupport = appSupportDirectory
|
||||
} else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
resolvedAppSupport = discovered
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? bundleIdentifier!
|
||||
: "com.cmuxterm.app"
|
||||
let safeBundleId = bundleId.replacingOccurrences(
|
||||
of: "[^A-Za-z0-9._-]",
|
||||
with: "_",
|
||||
options: .regularExpression
|
||||
)
|
||||
return resolvedAppSupport
|
||||
.appendingPathComponent("cmux", isDirectory: true)
|
||||
.appendingPathComponent("session-\(safeBundleId).json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionScrollbackReplayStore {
|
||||
static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE"
|
||||
private static let directoryName = "cmux-session-scrollback"
|
||||
private static let ansiEscape = "\u{001B}"
|
||||
private static let ansiReset = "\u{001B}[0m"
|
||||
|
||||
static func replayEnvironment(
|
||||
for scrollback: String?,
|
||||
tempDirectory: URL = FileManager.default.temporaryDirectory
|
||||
) -> [String: String] {
|
||||
guard let replayText = normalizedScrollback(scrollback) else { return [:] }
|
||||
guard let replayFileURL = writeReplayFile(
|
||||
contents: replayText,
|
||||
tempDirectory: tempDirectory
|
||||
) else {
|
||||
return [:]
|
||||
}
|
||||
return [environmentKey: replayFileURL.path]
|
||||
}
|
||||
|
||||
private static func normalizedScrollback(_ scrollback: String?) -> String? {
|
||||
guard let scrollback else { return nil }
|
||||
guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil }
|
||||
guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil }
|
||||
return ansiSafeReplayText(truncated)
|
||||
}
|
||||
|
||||
/// Preserve ANSI color state safely across replay boundaries.
|
||||
private static func ansiSafeReplayText(_ text: String) -> String {
|
||||
guard text.contains(ansiEscape) else { return text }
|
||||
var output = text
|
||||
if !output.hasPrefix(ansiReset) {
|
||||
output = ansiReset + output
|
||||
}
|
||||
if !output.hasSuffix(ansiReset) {
|
||||
output += ansiReset
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? {
|
||||
guard let data = contents.data(using: .utf8) else { return nil }
|
||||
let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
let fileURL = directory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: false)
|
||||
.appendingPathExtension("txt")
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
return fileURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@ import SwiftUI
|
|||
|
||||
@MainActor
|
||||
final class SidebarSelectionState: ObservableObject {
|
||||
@Published var selection: SidebarSelection = .tabs
|
||||
}
|
||||
@Published var selection: SidebarSelection
|
||||
|
||||
init(selection: SidebarSelection = .tabs) {
|
||||
self.selection = selection
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ struct SocketControlSettings {
|
|||
static let legacyEnabledKey = "socketControlEnabled"
|
||||
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
|
||||
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
|
||||
static let launchTagEnvKey = "CMUX_TAG"
|
||||
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
|
||||
|
||||
private static func normalizeMode(_ raw: String) -> String {
|
||||
raw
|
||||
|
|
@ -211,6 +213,53 @@ struct SocketControlSettings {
|
|||
#endif
|
||||
}
|
||||
|
||||
static func launchTag(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> String? {
|
||||
guard let raw = environment[launchTagEnvKey] else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func shouldBlockUntaggedDebugLaunch(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
|
||||
) -> Bool {
|
||||
guard isDebugBuild else { return false }
|
||||
if isRunningUnderXCTest(environment: environment) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") {
|
||||
return false
|
||||
}
|
||||
|
||||
guard bundleIdentifier == baseDebugBundleIdentifier else {
|
||||
return false
|
||||
}
|
||||
|
||||
return launchTag(environment: environment) == nil
|
||||
}
|
||||
|
||||
static func isRunningUnderXCTest(environment: [String: String]) -> Bool {
|
||||
let indicators = [
|
||||
"XCTestConfigurationFilePath",
|
||||
"XCTestBundlePath",
|
||||
"XCTestSessionIdentifier",
|
||||
"XCInjectBundleInto",
|
||||
]
|
||||
return indicators.contains { key in
|
||||
guard let value = environment[key] else { return false }
|
||||
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
static func socketPath(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
|
|
|
|||
|
|
@ -558,6 +558,10 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
|
|||
|
||||
@MainActor
|
||||
class TabManager: ObservableObject {
|
||||
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
|
||||
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
|
||||
weak var window: NSWindow?
|
||||
|
||||
@Published var tabs: [Workspace] = []
|
||||
@Published private(set) var isWorkspaceCycleHot: Bool = false
|
||||
|
||||
|
|
@ -567,6 +571,9 @@ class TabManager: ObservableObject {
|
|||
@Published var selectedTabId: UUID? {
|
||||
didSet {
|
||||
guard selectedTabId != oldValue else { return }
|
||||
sentryBreadcrumb("workspace.switch", data: [
|
||||
"tabCount": tabs.count
|
||||
])
|
||||
let previousTabId = oldValue
|
||||
if let previousTabId,
|
||||
let previousPanelId = focusedPanelId(for: previousTabId) {
|
||||
|
|
@ -751,13 +758,24 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
@discardableResult
|
||||
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace {
|
||||
func addWorkspace(
|
||||
workingDirectory overrideWorkingDirectory: String? = nil,
|
||||
select: Bool = true,
|
||||
placementOverride: NewWorkspacePlacement? = nil
|
||||
) -> Workspace {
|
||||
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
|
||||
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
|
||||
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
|
||||
let ordinal = Self.nextPortOrdinal
|
||||
Self.nextPortOrdinal += 1
|
||||
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal)
|
||||
let newWorkspace = Workspace(
|
||||
title: "Terminal \(tabs.count + 1)",
|
||||
workingDirectory: workingDirectory,
|
||||
portOrdinal: ordinal,
|
||||
configTemplate: inheritedConfig
|
||||
)
|
||||
wireClosedBrowserTracking(for: newWorkspace)
|
||||
let insertIndex = newTabInsertIndex()
|
||||
let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
|
||||
if insertIndex >= 0 && insertIndex <= tabs.count {
|
||||
tabs.insert(newWorkspace, at: insertIndex)
|
||||
} else {
|
||||
|
|
@ -785,6 +803,36 @@ class TabManager: ObservableObject {
|
|||
@discardableResult
|
||||
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
|
||||
|
||||
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
|
||||
guard let workspace = selectedWorkspace else { return nil }
|
||||
if let focusedTerminal = workspace.focusedTerminalPanel {
|
||||
return focusedTerminal
|
||||
}
|
||||
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() {
|
||||
return rememberedTerminal
|
||||
}
|
||||
if let focusedPaneId = workspace.bonsplitController.focusedPaneId,
|
||||
let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) {
|
||||
return paneTerminal
|
||||
}
|
||||
return workspace.terminalPanelForConfigInheritance()
|
||||
}
|
||||
|
||||
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
|
||||
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
|
||||
return cmuxInheritedSurfaceConfig(
|
||||
sourceSurface: sourceSurface,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_TAB
|
||||
)
|
||||
}
|
||||
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
|
||||
var config = ghostty_surface_config_new()
|
||||
config.font_size = fallbackFontPoints
|
||||
return config
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
|
||||
guard let directory else { return nil }
|
||||
let normalized = normalizeDirectory(directory)
|
||||
|
|
@ -792,8 +840,8 @@ class TabManager: ObservableObject {
|
|||
return trimmed.isEmpty ? nil : normalized
|
||||
}
|
||||
|
||||
private func newTabInsertIndex() -> Int {
|
||||
let placement = WorkspacePlacementSettings.current()
|
||||
private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int {
|
||||
let placement = placementOverride ?? WorkspacePlacementSettings.current()
|
||||
let pinnedCount = tabs.filter { $0.isPinned }.count
|
||||
let selectedIndex = selectedTabId.flatMap { tabId in
|
||||
tabs.firstIndex(where: { $0.id == tabId })
|
||||
|
|
@ -927,6 +975,7 @@ class TabManager: ObservableObject {
|
|||
|
||||
func closeWorkspace(_ workspace: Workspace) {
|
||||
guard tabs.count > 1 else { return }
|
||||
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
|
||||
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
|
||||
unwireClosedBrowserTracking(for: workspace)
|
||||
|
|
@ -1137,11 +1186,24 @@ class TabManager: ObservableObject {
|
|||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
||||
guard tab.panels[surfaceId] != nil else { return }
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " +
|
||||
"surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)"
|
||||
)
|
||||
#endif
|
||||
|
||||
// Keep AppKit first responder in sync with workspace focus before routing the close.
|
||||
// If split reparenting caused a temporary model/view mismatch, fallback close logic in
|
||||
// Workspace.closePanel uses focused selection to resolve the correct tab deterministically.
|
||||
reconcileFocusedPanelFromFirstResponderForKeyboard()
|
||||
_ = tab.closePanel(surfaceId, force: true)
|
||||
let closed = tab.closePanel(surfaceId, force: true)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " +
|
||||
"surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)"
|
||||
)
|
||||
#endif
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId)
|
||||
}
|
||||
|
||||
|
|
@ -1153,6 +1215,13 @@ class TabManager: ObservableObject {
|
|||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
||||
guard tab.panels[surfaceId] != nil else { return }
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " +
|
||||
"surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)"
|
||||
)
|
||||
#endif
|
||||
|
||||
// Child-exit on the last panel should collapse the workspace, matching explicit close
|
||||
// semantics (and close the window when it was the last workspace).
|
||||
if tab.panels.count <= 1 {
|
||||
|
|
@ -1435,8 +1504,8 @@ class TabManager: ObservableObject {
|
|||
|
||||
private func updateWindowTitle(for tab: Workspace?) {
|
||||
let title = windowTitle(for: tab)
|
||||
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first
|
||||
targetWindow?.title = title
|
||||
guard let targetWindow = window else { return }
|
||||
targetWindow.title = title
|
||||
}
|
||||
|
||||
private func windowTitle(for tab: Workspace?) -> String {
|
||||
|
|
@ -1450,7 +1519,11 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) {
|
||||
guard tabs.contains(where: { $0.id == tabId }) else { return }
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
||||
if let surfaceId, tab.panels[surfaceId] != nil {
|
||||
// Keep selected-surface intent stable across selectedTabId didSet async restore.
|
||||
lastFocusedPanelByTab[tabId] = surfaceId
|
||||
}
|
||||
selectedTabId = tabId
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
|
|
@ -1458,10 +1531,15 @@ class TabManager: ObservableObject {
|
|||
userInfo: [GhosttyNotificationKey.tabId: tabId]
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.unhide(nil)
|
||||
if let window = NSApp.keyWindow ?? NSApp.windows.first {
|
||||
if let app = AppDelegate.shared,
|
||||
let windowId = app.windowId(for: self),
|
||||
let window = app.mainWindow(for: windowId) {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
} else if let window = NSApp.keyWindow ?? NSApp.windows.first {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -1469,7 +1547,7 @@ class TabManager: ObservableObject {
|
|||
if let surfaceId {
|
||||
if !suppressFlash {
|
||||
focusSurface(tabId: tabId, surfaceId: surfaceId)
|
||||
} else if let tab = tabs.first(where: { $0.id == tabId }) {
|
||||
} else {
|
||||
tab.focusPanel(surfaceId)
|
||||
}
|
||||
}
|
||||
|
|
@ -1665,6 +1743,7 @@ class TabManager: ObservableObject {
|
|||
guard let selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == selectedTabId }),
|
||||
let focusedPanelId = tab.focusedPanelId else { return }
|
||||
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
|
||||
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
|
||||
}
|
||||
|
||||
|
|
@ -2732,6 +2811,10 @@ class TabManager: ObservableObject {
|
|||
let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1"
|
||||
let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d"
|
||||
let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d"
|
||||
let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger
|
||||
let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger
|
||||
let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expectedPanelsAfter = max(
|
||||
|
|
@ -2870,7 +2953,9 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
tab.focusPanel(exitPanelId)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
if !useEarlyTrigger {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
|
||||
let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? ""
|
||||
let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in
|
||||
|
|
@ -2974,21 +3059,31 @@ class TabManager: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
// Wait for the target panel to be fully attached after split churn.
|
||||
let readyDeadline = Date().addingTimeInterval(2.0)
|
||||
let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift
|
||||
? [.control, .shift]
|
||||
: [.control]
|
||||
let shouldWaitForSurface = !useEarlyTrigger
|
||||
|
||||
var attachedBeforeTrigger = false
|
||||
var hasSurfaceBeforeTrigger = false
|
||||
while Date() < readyDeadline {
|
||||
guard let panel = tab.terminalPanel(for: exitPanelId) else {
|
||||
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
|
||||
return
|
||||
if shouldWaitForSurface {
|
||||
// Wait for the target panel to be fully attached after split churn.
|
||||
let readyDeadline = Date().addingTimeInterval(2.0)
|
||||
while Date() < readyDeadline {
|
||||
guard let panel = tab.terminalPanel(for: exitPanelId) else {
|
||||
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
|
||||
return
|
||||
}
|
||||
attachedBeforeTrigger = panel.hostedView.window != nil
|
||||
hasSurfaceBeforeTrigger = panel.surface.surface != nil
|
||||
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
} else if let panel = tab.terminalPanel(for: exitPanelId) {
|
||||
attachedBeforeTrigger = panel.hostedView.window != nil
|
||||
hasSurfaceBeforeTrigger = panel.surface.surface != nil
|
||||
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
write([
|
||||
"exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0",
|
||||
|
|
@ -3000,7 +3095,7 @@ class TabManager: ObservableObject {
|
|||
return
|
||||
}
|
||||
// Exercise the real key path (ghostty_surface_key for Ctrl+D).
|
||||
if panel.hostedView.sendSyntheticCtrlDForUITest() {
|
||||
if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
|
||||
write(["autoTriggerSentCtrlDKey1": "1"])
|
||||
} else {
|
||||
write([
|
||||
|
|
@ -3012,13 +3107,20 @@ class TabManager: ObservableObject {
|
|||
|
||||
// In strict mode, never mask routing bugs with fallback writes.
|
||||
if strictKeyOnly {
|
||||
write(["autoTriggerMode": "strict_ctrl_d"])
|
||||
let strictModeLabel: String = {
|
||||
if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" }
|
||||
if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" }
|
||||
if triggerUsesShift { return "strict_ctrl_shift_d" }
|
||||
return "strict_ctrl_d"
|
||||
}()
|
||||
write(["autoTriggerMode": strictModeLabel])
|
||||
return
|
||||
}
|
||||
|
||||
// Non-strict mode keeps one additional Ctrl+D retry for startup timing variance.
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() {
|
||||
if tab.panels[exitPanelId] != nil,
|
||||
panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
|
||||
write(["autoTriggerSentCtrlDKey2": "1"])
|
||||
}
|
||||
}
|
||||
|
|
@ -3028,6 +3130,75 @@ class TabManager: ObservableObject {
|
|||
#endif
|
||||
}
|
||||
|
||||
extension TabManager {
|
||||
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
|
||||
let workspaceSnapshots = tabs
|
||||
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
|
||||
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
|
||||
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
|
||||
tabs.firstIndex(where: { $0.id == selectedTabId })
|
||||
}
|
||||
return SessionTabManagerSnapshot(
|
||||
selectedWorkspaceIndex: selectedWorkspaceIndex,
|
||||
workspaces: workspaceSnapshots
|
||||
)
|
||||
}
|
||||
|
||||
func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) {
|
||||
for tab in tabs {
|
||||
unwireClosedBrowserTracking(for: tab)
|
||||
}
|
||||
|
||||
tabs.removeAll(keepingCapacity: false)
|
||||
lastFocusedPanelByTab.removeAll()
|
||||
pendingPanelTitleUpdates.removeAll()
|
||||
tabHistory.removeAll()
|
||||
historyIndex = -1
|
||||
isNavigatingHistory = false
|
||||
pendingWorkspaceUnfocusTarget = nil
|
||||
workspaceCycleCooldownTask?.cancel()
|
||||
workspaceCycleCooldownTask = nil
|
||||
isWorkspaceCycleHot = false
|
||||
selectionSideEffectsGeneration &+= 1
|
||||
recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
|
||||
|
||||
let workspaceSnapshots = snapshot.workspaces
|
||||
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
|
||||
for workspaceSnapshot in workspaceSnapshots {
|
||||
let ordinal = Self.nextPortOrdinal
|
||||
Self.nextPortOrdinal += 1
|
||||
let workspace = Workspace(
|
||||
title: workspaceSnapshot.processTitle,
|
||||
workingDirectory: workspaceSnapshot.currentDirectory,
|
||||
portOrdinal: ordinal
|
||||
)
|
||||
workspace.restoreSessionSnapshot(workspaceSnapshot)
|
||||
wireClosedBrowserTracking(for: workspace)
|
||||
tabs.append(workspace)
|
||||
}
|
||||
|
||||
if tabs.isEmpty {
|
||||
_ = addWorkspace(select: false)
|
||||
}
|
||||
|
||||
selectedTabId = nil
|
||||
if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex,
|
||||
tabs.indices.contains(selectedWorkspaceIndex) {
|
||||
selectedTabId = tabs[selectedWorkspaceIndex].id
|
||||
} else {
|
||||
selectedTabId = tabs.first?.id
|
||||
}
|
||||
|
||||
if let selectedTabId {
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
object: nil,
|
||||
userInfo: [GhosttyNotificationKey.tabId: selectedTabId]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Direction Types for Backwards Compatibility
|
||||
|
||||
/// Split direction for backwards compatibility with old API
|
||||
|
|
@ -3055,15 +3226,22 @@ enum ResizeDirection {
|
|||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested")
|
||||
static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested")
|
||||
static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested")
|
||||
static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested")
|
||||
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
|
||||
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
|
||||
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
|
||||
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
|
||||
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
||||
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
||||
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
|
||||
static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView")
|
||||
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
|
||||
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
|
||||
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
|
||||
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
|
||||
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
|
||||
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
|
||||
static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class TerminalController {
|
|||
"browser.focus_webview",
|
||||
"browser.focus",
|
||||
"browser.tab.switch",
|
||||
"debug.command_palette.toggle",
|
||||
"debug.notification.focus",
|
||||
"debug.app.activate"
|
||||
]
|
||||
|
|
@ -1336,6 +1337,28 @@ class TerminalController {
|
|||
return v2Result(id: id, self.v2DebugType(params: params))
|
||||
case "debug.app.activate":
|
||||
return v2Result(id: id, self.v2DebugActivateApp())
|
||||
case "debug.command_palette.toggle":
|
||||
return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params))
|
||||
case "debug.command_palette.rename_tab.open":
|
||||
return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params))
|
||||
case "debug.command_palette.visible":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params))
|
||||
case "debug.command_palette.selection":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params))
|
||||
case "debug.command_palette.results":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params))
|
||||
case "debug.command_palette.rename_input.interact":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params))
|
||||
case "debug.command_palette.rename_input.delete_backward":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params))
|
||||
case "debug.command_palette.rename_input.selection":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params))
|
||||
case "debug.command_palette.rename_input.select_all":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params))
|
||||
case "debug.browser.address_bar_focused":
|
||||
return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params))
|
||||
case "debug.sidebar.visible":
|
||||
return v2Result(id: id, self.v2DebugSidebarVisible(params: params))
|
||||
case "debug.terminal.is_focused":
|
||||
return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params))
|
||||
case "debug.terminal.read_text":
|
||||
|
|
@ -1532,6 +1555,17 @@ class TerminalController {
|
|||
"debug.shortcut.simulate",
|
||||
"debug.type",
|
||||
"debug.app.activate",
|
||||
"debug.command_palette.toggle",
|
||||
"debug.command_palette.rename_tab.open",
|
||||
"debug.command_palette.visible",
|
||||
"debug.command_palette.selection",
|
||||
"debug.command_palette.results",
|
||||
"debug.command_palette.rename_input.interact",
|
||||
"debug.command_palette.rename_input.delete_backward",
|
||||
"debug.command_palette.rename_input.selection",
|
||||
"debug.command_palette.rename_input.select_all",
|
||||
"debug.browser.address_bar_focused",
|
||||
"debug.sidebar.visible",
|
||||
"debug.terminal.is_focused",
|
||||
"debug.terminal.read_text",
|
||||
"debug.terminal.render_stats",
|
||||
|
|
@ -3543,6 +3577,154 @@ class TerminalController {
|
|||
return "OK \(base64)"
|
||||
}
|
||||
|
||||
private struct PasteboardItemSnapshot {
|
||||
let representations: [(type: NSPasteboard.PasteboardType, data: Data)]
|
||||
}
|
||||
|
||||
nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if let url = URL(string: trimmed),
|
||||
url.isFileURL,
|
||||
!url.path.isEmpty {
|
||||
return url.path
|
||||
}
|
||||
return trimmed.hasPrefix("/") ? trimmed : nil
|
||||
}
|
||||
|
||||
nonisolated static func shouldRemoveExportedScreenFile(
|
||||
fileURL: URL,
|
||||
temporaryDirectory: URL = FileManager.default.temporaryDirectory
|
||||
) -> Bool {
|
||||
let standardizedFile = fileURL.standardizedFileURL
|
||||
let temporary = temporaryDirectory.standardizedFileURL
|
||||
return standardizedFile.path.hasPrefix(temporary.path + "/")
|
||||
}
|
||||
|
||||
nonisolated static func shouldRemoveExportedScreenDirectory(
|
||||
fileURL: URL,
|
||||
temporaryDirectory: URL = FileManager.default.temporaryDirectory
|
||||
) -> Bool {
|
||||
let directory = fileURL.deletingLastPathComponent().standardizedFileURL
|
||||
let temporary = temporaryDirectory.standardizedFileURL
|
||||
return directory.path.hasPrefix(temporary.path + "/")
|
||||
}
|
||||
|
||||
private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] {
|
||||
guard let items = pasteboard.pasteboardItems else { return [] }
|
||||
return items.map { item in
|
||||
let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in
|
||||
guard let data = item.data(forType: type) else { return nil }
|
||||
return (type: type, data: data)
|
||||
}
|
||||
return PasteboardItemSnapshot(representations: representations)
|
||||
}
|
||||
}
|
||||
|
||||
private func restorePasteboardItems(
|
||||
_ snapshots: [PasteboardItemSnapshot],
|
||||
to pasteboard: NSPasteboard
|
||||
) {
|
||||
_ = pasteboard.clearContents()
|
||||
guard !snapshots.isEmpty else { return }
|
||||
|
||||
let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in
|
||||
guard !snapshot.representations.isEmpty else { return nil }
|
||||
let item = NSPasteboardItem()
|
||||
for representation in snapshot.representations {
|
||||
item.setData(representation.data, forType: representation.type)
|
||||
}
|
||||
return item
|
||||
}
|
||||
guard !restoredItems.isEmpty else { return }
|
||||
_ = pasteboard.writeObjects(restoredItems)
|
||||
}
|
||||
|
||||
private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? {
|
||||
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
|
||||
let firstURL = urls.first,
|
||||
firstURL.isFileURL {
|
||||
return firstURL.path
|
||||
}
|
||||
if let value = pasteboard.string(forType: .string) {
|
||||
return value
|
||||
}
|
||||
return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text"))
|
||||
}
|
||||
|
||||
private func readTerminalTextFromVTExportForSnapshot(
|
||||
terminalPanel: TerminalPanel,
|
||||
lineLimit: Int?
|
||||
) -> String? {
|
||||
// read_text strips style state; VT export keeps ANSI escape sequences.
|
||||
let pasteboard = NSPasteboard.general
|
||||
let snapshot = snapshotPasteboardItems(pasteboard)
|
||||
defer {
|
||||
restorePasteboardItems(snapshot, to: pasteboard)
|
||||
}
|
||||
|
||||
let initialChangeCount = pasteboard.changeCount
|
||||
guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else {
|
||||
return nil
|
||||
}
|
||||
guard pasteboard.changeCount != initialChangeCount else {
|
||||
return nil
|
||||
}
|
||||
guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileURL = URL(fileURLWithPath: exportedPath)
|
||||
defer {
|
||||
if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) {
|
||||
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
var output = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
if let lineLimit {
|
||||
output = tailTerminalLines(output, maxLines: lineLimit)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func readTerminalTextForSnapshot(
|
||||
terminalPanel: TerminalPanel,
|
||||
includeScrollback: Bool = false,
|
||||
lineLimit: Int? = nil
|
||||
) -> String? {
|
||||
if includeScrollback,
|
||||
let vtOutput = readTerminalTextFromVTExportForSnapshot(
|
||||
terminalPanel: terminalPanel,
|
||||
lineLimit: lineLimit
|
||||
) {
|
||||
return vtOutput
|
||||
}
|
||||
|
||||
let response = readTerminalTextBase64(
|
||||
terminalPanel: terminalPanel,
|
||||
includeScrollback: includeScrollback,
|
||||
lineLimit: lineLimit
|
||||
)
|
||||
guard response.hasPrefix("OK ") else { return nil }
|
||||
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if base64.isEmpty {
|
||||
return ""
|
||||
}
|
||||
guard let data = Data(base64Encoded: base64),
|
||||
let decoded = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
|
|
@ -7621,6 +7803,294 @@ class TerminalController {
|
|||
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
||||
}
|
||||
|
||||
private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult {
|
||||
let requestedWindowId = v2UUID(params, "window_id")
|
||||
var result: V2CallResult = .ok([:])
|
||||
DispatchQueue.main.sync {
|
||||
let targetWindow: NSWindow?
|
||||
if let requestedWindowId {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)]
|
||||
)
|
||||
return
|
||||
}
|
||||
targetWindow = window
|
||||
} else {
|
||||
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult {
|
||||
let requestedWindowId = v2UUID(params, "window_id")
|
||||
var result: V2CallResult = .ok([:])
|
||||
DispatchQueue.main.sync {
|
||||
let targetWindow: NSWindow?
|
||||
if let requestedWindowId {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: [
|
||||
"window_id": requestedWindowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
targetWindow = window
|
||||
} else {
|
||||
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
var visible = false
|
||||
DispatchQueue.main.sync {
|
||||
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
||||
}
|
||||
return .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"visible": visible
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
var visible = false
|
||||
var selectedIndex = 0
|
||||
DispatchQueue.main.sync {
|
||||
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
||||
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
|
||||
}
|
||||
return .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"visible": visible,
|
||||
"selected_index": max(0, selectedIndex)
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
let requestedLimit = params["limit"] as? Int
|
||||
let limit = max(1, min(100, requestedLimit ?? 20))
|
||||
|
||||
var visible = false
|
||||
var selectedIndex = 0
|
||||
var snapshot = CommandPaletteDebugSnapshot.empty
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
||||
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
|
||||
snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty
|
||||
}
|
||||
|
||||
let rows = Array(snapshot.results.prefix(limit)).map { row in
|
||||
[
|
||||
"command_id": row.commandId,
|
||||
"title": row.title,
|
||||
"shortcut_hint": v2OrNull(row.shortcutHint),
|
||||
"trailing_label": v2OrNull(row.trailingLabel),
|
||||
"score": row.score
|
||||
] as [String: Any]
|
||||
}
|
||||
|
||||
return .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"visible": visible,
|
||||
"selected_index": max(0, selectedIndex),
|
||||
"query": snapshot.query,
|
||||
"mode": snapshot.mode,
|
||||
"results": rows
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult {
|
||||
let requestedWindowId = v2UUID(params, "window_id")
|
||||
var result: V2CallResult = .ok([:])
|
||||
DispatchQueue.main.sync {
|
||||
let targetWindow: NSWindow?
|
||||
if let requestedWindowId {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: [
|
||||
"window_id": requestedWindowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
targetWindow = window
|
||||
} else {
|
||||
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult {
|
||||
let requestedWindowId = v2UUID(params, "window_id")
|
||||
var result: V2CallResult = .ok([:])
|
||||
DispatchQueue.main.sync {
|
||||
let targetWindow: NSWindow?
|
||||
if let requestedWindowId {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: [
|
||||
"window_id": requestedWindowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
targetWindow = window
|
||||
} else {
|
||||
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
|
||||
var result: V2CallResult = .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"focused": false,
|
||||
"selection_location": 0,
|
||||
"selection_length": 0,
|
||||
"text_length": 0
|
||||
])
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: windowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else {
|
||||
return
|
||||
}
|
||||
let selectedRange = editor.selectedRange()
|
||||
let textLength = (editor.string as NSString).length
|
||||
result = .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"focused": true,
|
||||
"selection_location": max(0, selectedRange.location),
|
||||
"selection_length": max(0, selectedRange.length),
|
||||
"text_length": max(0, textLength)
|
||||
])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult {
|
||||
if let rawEnabled = params["enabled"] {
|
||||
guard let enabled = rawEnabled as? Bool else {
|
||||
return .err(
|
||||
code: "invalid_params",
|
||||
message: "enabled must be a bool",
|
||||
data: ["enabled": rawEnabled]
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.sync {
|
||||
UserDefaults.standard.set(
|
||||
enabled,
|
||||
forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
DispatchQueue.main.sync {
|
||||
enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled()
|
||||
}
|
||||
|
||||
return .ok([
|
||||
"enabled": enabled
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult {
|
||||
let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id")
|
||||
var focusedSurfaceId: UUID?
|
||||
DispatchQueue.main.sync {
|
||||
focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId()
|
||||
}
|
||||
|
||||
var payload: [String: Any] = [
|
||||
"focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString),
|
||||
"focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
|
||||
"focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString),
|
||||
"focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
|
||||
"focused": focusedSurfaceId != nil
|
||||
]
|
||||
|
||||
if let requestedSurfaceId {
|
||||
payload["surface_id"] = requestedSurfaceId.uuidString
|
||||
payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
|
||||
payload["panel_id"] = requestedSurfaceId.uuidString
|
||||
payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
|
||||
payload["focused"] = (focusedSurfaceId == requestedSurfaceId)
|
||||
}
|
||||
|
||||
return .ok(payload)
|
||||
}
|
||||
|
||||
private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
var visibility: Bool?
|
||||
DispatchQueue.main.sync {
|
||||
visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId)
|
||||
}
|
||||
guard let visible = visibility else {
|
||||
return .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
|
||||
)
|
||||
}
|
||||
return .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"visible": visible
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult {
|
||||
guard let surfaceId = v2String(params, "surface_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
|
||||
|
|
@ -8003,6 +8473,37 @@ class TerminalController {
|
|||
}
|
||||
|
||||
#if DEBUG
|
||||
private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String {
|
||||
let snakeCase = action.rawValue.replacingOccurrences(
|
||||
of: "([a-z0-9])([A-Z])",
|
||||
with: "$1_$2",
|
||||
options: .regularExpression
|
||||
)
|
||||
return snakeCase.lowercased()
|
||||
}
|
||||
|
||||
private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? {
|
||||
let normalized = rawName
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "-", with: "_")
|
||||
|
||||
for action in KeyboardShortcutSettings.Action.allCases {
|
||||
let snakeCaseName = debugShortcutName(for: action)
|
||||
if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") {
|
||||
return action
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func debugShortcutSupportedNames() -> String {
|
||||
KeyboardShortcutSettings.Action.allCases
|
||||
.map(debugShortcutName(for:))
|
||||
.sorted()
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func setShortcut(_ args: String) -> String {
|
||||
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
|
|
@ -8010,29 +8511,15 @@ class TerminalController {
|
|||
return "ERROR: Usage: set_shortcut <name> <combo|clear>"
|
||||
}
|
||||
|
||||
let name = parts[0].lowercased()
|
||||
let name = parts[0]
|
||||
let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let defaultsKey: String?
|
||||
switch name {
|
||||
case "focus_left", "focusleft":
|
||||
defaultsKey = KeyboardShortcutSettings.focusLeftKey
|
||||
case "focus_right", "focusright":
|
||||
defaultsKey = KeyboardShortcutSettings.focusRightKey
|
||||
case "focus_up", "focusup":
|
||||
defaultsKey = KeyboardShortcutSettings.focusUpKey
|
||||
case "focus_down", "focusdown":
|
||||
defaultsKey = KeyboardShortcutSettings.focusDownKey
|
||||
default:
|
||||
defaultsKey = nil
|
||||
}
|
||||
|
||||
guard let defaultsKey else {
|
||||
return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down"
|
||||
guard let action = debugShortcutAction(named: name) else {
|
||||
return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())"
|
||||
}
|
||||
|
||||
if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" {
|
||||
UserDefaults.standard.removeObject(forKey: defaultsKey)
|
||||
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
|
||||
return "OK"
|
||||
}
|
||||
|
||||
|
|
@ -8050,7 +8537,7 @@ class TerminalController {
|
|||
guard let data = try? JSONEncoder().encode(shortcut) else {
|
||||
return "ERROR: Failed to encode shortcut"
|
||||
}
|
||||
UserDefaults.standard.set(data, forKey: defaultsKey)
|
||||
UserDefaults.standard.set(data, forKey: action.defaultsKey)
|
||||
return "OK"
|
||||
}
|
||||
|
||||
|
|
@ -8069,17 +8556,24 @@ class TerminalController {
|
|||
|
||||
var result = "ERROR: Failed to create event"
|
||||
DispatchQueue.main.sync {
|
||||
// Tests can run while the app is activating (no keyWindow yet). Prefer a visible
|
||||
// window to keep input simulation deterministic in debug builds.
|
||||
let targetWindow = NSApp.keyWindow
|
||||
?? NSApp.mainWindow
|
||||
?? NSApp.windows.first(where: { $0.isVisible })
|
||||
?? NSApp.windows.first
|
||||
// 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 = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0
|
||||
let windowNumber = targetWindow?.windowNumber ?? 0
|
||||
guard let keyDownEvent = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
|
|
@ -8158,20 +8652,20 @@ class TerminalController {
|
|||
// Socket commands are line-based; allow callers to express control chars with backslash escapes.
|
||||
let text = unescapeSocketText(raw)
|
||||
|
||||
var result = "ERROR: No window"
|
||||
DispatchQueue.main.sync {
|
||||
// Like simulate_shortcut, prefer a visible window so debug automation doesn't
|
||||
// fail during key window transitions.
|
||||
guard let window = NSApp.keyWindow
|
||||
?? NSApp.mainWindow
|
||||
?? NSApp.windows.first(where: { $0.isVisible })
|
||||
?? NSApp.windows.first else { return }
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
guard let fr = window.firstResponder else {
|
||||
result = "ERROR: No first responder"
|
||||
return
|
||||
}
|
||||
var result = "ERROR: No window"
|
||||
DispatchQueue.main.sync {
|
||||
// Like simulate_shortcut, prefer a visible window so debug automation doesn't
|
||||
// fail during key window transitions.
|
||||
guard let window = NSApp.keyWindow
|
||||
?? NSApp.mainWindow
|
||||
?? NSApp.windows.first(where: { $0.isVisible })
|
||||
?? NSApp.windows.first else { return }
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
guard let fr = window.firstResponder else {
|
||||
result = "ERROR: No first responder"
|
||||
return
|
||||
}
|
||||
|
||||
if let client = fr as? NSTextInputClient {
|
||||
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
|
||||
|
|
@ -8179,7 +8673,22 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
|
||||
// Fall back to the responder chain insertText action.
|
||||
// If workspace handoff temporarily leaves a non-terminal first responder,
|
||||
// route debug typing to the selected terminal's focused panel directly.
|
||||
if let tabManager,
|
||||
let tabId = tabManager.selectedTabId,
|
||||
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
||||
let panelId = tab.focusedPanelId,
|
||||
let terminalPanel = tab.terminalPanel(for: panelId),
|
||||
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
|
||||
// Match Enter semantics expected by tests/debug tooling when bypassing AppKit.
|
||||
let directText = text.replacingOccurrences(of: "\n", with: "\r")
|
||||
terminalPanel.surface.sendText(directText)
|
||||
result = "OK"
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to the responder-chain insertText action.
|
||||
(fr as? NSResponder)?.insertText(text)
|
||||
result = "OK"
|
||||
}
|
||||
|
|
@ -8772,6 +9281,10 @@ class TerminalController {
|
|||
let charactersIgnoringModifiers: String
|
||||
|
||||
switch keyToken.lowercased() {
|
||||
case "esc", "escape":
|
||||
storedKey = "\u{1b}"
|
||||
keyCode = UInt16(kVK_Escape)
|
||||
charactersIgnoringModifiers = storedKey
|
||||
case "left":
|
||||
storedKey = "←"
|
||||
keyCode = 123
|
||||
|
|
@ -8792,6 +9305,10 @@ class TerminalController {
|
|||
storedKey = "\r"
|
||||
keyCode = UInt16(kVK_Return)
|
||||
charactersIgnoringModifiers = storedKey
|
||||
case "backspace", "delete", "del":
|
||||
storedKey = "\u{7f}"
|
||||
keyCode = UInt16(kVK_Delete)
|
||||
charactersIgnoringModifiers = storedKey
|
||||
default:
|
||||
let key = keyToken.lowercased()
|
||||
guard let code = keyCodeForShortcutKey(key) else { return nil }
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ private func portalDebugToken(_ view: NSView?) -> String {
|
|||
private func portalDebugFrame(_ rect: NSRect) -> String {
|
||||
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
|
||||
}
|
||||
|
||||
private func portalDebugFrameInWindow(_ view: NSView?) -> String {
|
||||
guard let view else { return "nil" }
|
||||
guard view.window != nil else { return "no-window" }
|
||||
return portalDebugFrame(view.convert(view.bounds, to: nil))
|
||||
}
|
||||
#endif
|
||||
|
||||
final class WindowTerminalHostView: NSView {
|
||||
|
|
@ -529,6 +535,10 @@ private final class SplitDividerOverlayView: NSView {
|
|||
|
||||
@MainActor
|
||||
final class WindowTerminalPortal: NSObject {
|
||||
private static let tinyHideThreshold: CGFloat = 1
|
||||
private static let minimumRevealWidth: CGFloat = 24
|
||||
private static let minimumRevealHeight: CGFloat = 18
|
||||
|
||||
private weak var window: NSWindow?
|
||||
private let hostView = WindowTerminalHostView(frame: .zero)
|
||||
private let dividerOverlayView = SplitDividerOverlayView(frame: .zero)
|
||||
|
|
@ -536,6 +546,11 @@ final class WindowTerminalPortal: NSObject {
|
|||
private weak var installedReferenceView: NSView?
|
||||
private var installConstraints: [NSLayoutConstraint] = []
|
||||
private var hasDeferredFullSyncScheduled = false
|
||||
private var hasExternalGeometrySyncScheduled = false
|
||||
private var geometryObservers: [NSObjectProtocol] = []
|
||||
#if DEBUG
|
||||
private var lastLoggedBonsplitContainerSignature: String?
|
||||
#endif
|
||||
|
||||
private struct Entry {
|
||||
weak var hostedView: GhosttySurfaceScrollView?
|
||||
|
|
@ -550,13 +565,141 @@ final class WindowTerminalPortal: NSObject {
|
|||
init(window: NSWindow) {
|
||||
self.window = window
|
||||
super.init()
|
||||
hostView.wantsLayer = false
|
||||
hostView.wantsLayer = true
|
||||
hostView.layer?.masksToBounds = true
|
||||
hostView.postsFrameChangedNotifications = true
|
||||
hostView.postsBoundsChangedNotifications = true
|
||||
hostView.translatesAutoresizingMaskIntoConstraints = false
|
||||
dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true
|
||||
dividerOverlayView.autoresizingMask = [.width, .height]
|
||||
installGeometryObservers(for: window)
|
||||
_ = ensureInstalled()
|
||||
}
|
||||
|
||||
private func installGeometryObservers(for window: NSWindow) {
|
||||
guard geometryObservers.isEmpty else { return }
|
||||
|
||||
let center = NotificationCenter.default
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSWindow.didResizeNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSWindow.didEndLiveResizeNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSSplitView.didResizeSubviewsNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self,
|
||||
let splitView = notification.object as? NSSplitView,
|
||||
let window = self.window,
|
||||
splitView.window === window else { return }
|
||||
self.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: hostView,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSView.boundsDidChangeNotification,
|
||||
object: hostView,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func removeGeometryObservers() {
|
||||
for observer in geometryObservers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
geometryObservers.removeAll()
|
||||
}
|
||||
|
||||
private func scheduleExternalGeometrySynchronize() {
|
||||
guard !hasExternalGeometrySyncScheduled else { return }
|
||||
hasExternalGeometrySyncScheduled = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.hasExternalGeometrySyncScheduled = false
|
||||
self.synchronizeAllEntriesFromExternalGeometryChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeLayoutHierarchy() {
|
||||
installedContainerView?.layoutSubtreeIfNeeded()
|
||||
installedReferenceView?.layoutSubtreeIfNeeded()
|
||||
hostView.superview?.layoutSubtreeIfNeeded()
|
||||
hostView.layoutSubtreeIfNeeded()
|
||||
_ = synchronizeHostFrameToReference()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func synchronizeHostFrameToReference() -> Bool {
|
||||
guard let container = installedContainerView,
|
||||
let reference = installedReferenceView else {
|
||||
return false
|
||||
}
|
||||
let frameInContainer = container.convert(reference.bounds, from: reference)
|
||||
let hasFiniteFrame =
|
||||
frameInContainer.origin.x.isFinite &&
|
||||
frameInContainer.origin.y.isFinite &&
|
||||
frameInContainer.size.width.isFinite &&
|
||||
frameInContainer.size.height.isFinite
|
||||
guard hasFiniteFrame else { return false }
|
||||
|
||||
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostView.frame = frameInContainer
|
||||
CATransaction.commit()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"portal.hostFrame.update host=\(portalDebugToken(hostView)) " +
|
||||
"frame=\(portalDebugFrame(frameInContainer))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
return frameInContainer.width > 1 && frameInContainer.height > 1
|
||||
}
|
||||
|
||||
private func synchronizeAllEntriesFromExternalGeometryChange() {
|
||||
guard ensureInstalled() else { return }
|
||||
synchronizeLayoutHierarchy()
|
||||
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.
|
||||
for entry in entriesByHostedId.values {
|
||||
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
|
||||
hostedView.reconcileGeometryNow()
|
||||
hostedView.refreshSurfaceNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDividerOverlayOnTop() {
|
||||
if dividerOverlayView.superview !== hostView {
|
||||
dividerOverlayView.frame = hostView.bounds
|
||||
|
|
@ -605,6 +748,8 @@ final class WindowTerminalPortal: NSObject {
|
|||
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
|
||||
}
|
||||
|
||||
synchronizeLayoutHierarchy()
|
||||
_ = synchronizeHostFrameToReference()
|
||||
ensureDividerOverlayOnTop()
|
||||
|
||||
return true
|
||||
|
|
@ -634,13 +779,32 @@ final class WindowTerminalPortal: NSObject {
|
|||
return false
|
||||
}
|
||||
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
|
||||
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
||||
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
||||
abs(lhs.size.width - rhs.size.width) <= epsilon &&
|
||||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
|
||||
guard rect.origin.x.isFinite,
|
||||
rect.origin.y.isFinite,
|
||||
rect.size.width.isFinite,
|
||||
rect.size.height.isFinite else {
|
||||
return rect
|
||||
}
|
||||
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
|
||||
func snap(_ value: CGFloat) -> CGFloat {
|
||||
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
|
||||
}
|
||||
return NSRect(
|
||||
x: snap(rect.origin.x),
|
||||
y: snap(rect.origin.y),
|
||||
width: max(0, snap(rect.size.width)),
|
||||
height: max(0, snap(rect.size.height))
|
||||
)
|
||||
}
|
||||
|
||||
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
|
||||
guard let viewIndex = container.subviews.firstIndex(of: view),
|
||||
let referenceIndex = container.subviews.firstIndex(of: reference) else {
|
||||
|
|
@ -649,6 +813,87 @@ final class WindowTerminalPortal: NSObject {
|
|||
return viewIndex > referenceIndex
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? {
|
||||
var current: NSView? = anchorView
|
||||
while let view = current {
|
||||
let className = NSStringFromClass(type(of: view))
|
||||
if className.contains("PaneDragContainerView") || className.contains("Bonsplit") {
|
||||
return view
|
||||
}
|
||||
current = view.superview
|
||||
}
|
||||
return installedReferenceView
|
||||
}
|
||||
|
||||
private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) {
|
||||
guard let container = nearestBonsplitContainer(from: anchorView) else { return }
|
||||
let containerFrame = container.convert(container.bounds, to: nil)
|
||||
let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))"
|
||||
guard signature != lastLoggedBonsplitContainerSignature else { return }
|
||||
lastLoggedBonsplitContainerSignature = signature
|
||||
|
||||
let containerClass = NSStringFromClass(type(of: container))
|
||||
dlog(
|
||||
"portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " +
|
||||
"class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " +
|
||||
"host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
|
||||
/// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when
|
||||
/// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective
|
||||
/// visible rect that should drive portal geometry.
|
||||
private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
|
||||
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
||||
var current = anchorView.superview
|
||||
while let ancestor = current {
|
||||
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
|
||||
let finiteAncestorBounds =
|
||||
ancestorBoundsInWindow.origin.x.isFinite &&
|
||||
ancestorBoundsInWindow.origin.y.isFinite &&
|
||||
ancestorBoundsInWindow.size.width.isFinite &&
|
||||
ancestorBoundsInWindow.size.height.isFinite
|
||||
if finiteAncestorBounds {
|
||||
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
|
||||
if frameInWindow.isNull { return .zero }
|
||||
}
|
||||
if ancestor === installedReferenceView { break }
|
||||
current = ancestor.superview
|
||||
}
|
||||
return frameInWindow
|
||||
}
|
||||
|
||||
private func seededFrameInHost(for anchorView: NSView) -> NSRect? {
|
||||
_ = synchronizeHostFrameToReference()
|
||||
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
|
||||
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
|
||||
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
|
||||
let hasFiniteFrame =
|
||||
frameInHost.origin.x.isFinite &&
|
||||
frameInHost.origin.y.isFinite &&
|
||||
frameInHost.size.width.isFinite &&
|
||||
frameInHost.size.height.isFinite
|
||||
guard hasFiniteFrame else { return nil }
|
||||
|
||||
let hostBounds = hostView.bounds
|
||||
let hasFiniteHostBounds =
|
||||
hostBounds.origin.x.isFinite &&
|
||||
hostBounds.origin.y.isFinite &&
|
||||
hostBounds.size.width.isFinite &&
|
||||
hostBounds.size.height.isFinite
|
||||
if hasFiniteHostBounds {
|
||||
let clampedFrame = frameInHost.intersection(hostBounds)
|
||||
if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 {
|
||||
return clampedFrame
|
||||
}
|
||||
}
|
||||
|
||||
return frameInHost
|
||||
}
|
||||
|
||||
func detachHostedView(withId hostedId: ObjectIdentifier) {
|
||||
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
|
||||
if let anchor = entry.anchorView {
|
||||
|
|
@ -689,6 +934,12 @@ final class WindowTerminalPortal: NSObject {
|
|||
entriesByHostedId[hostedId] = entry
|
||||
}
|
||||
|
||||
func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool {
|
||||
guard let entry = entriesByHostedId[hostedId],
|
||||
let boundAnchor = entry.anchorView else { return false }
|
||||
return boundAnchor === anchorView
|
||||
}
|
||||
|
||||
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard ensureInstalled() else { return }
|
||||
|
||||
|
|
@ -740,6 +991,32 @@ final class WindowTerminalPortal: NSObject {
|
|||
}
|
||||
#endif
|
||||
|
||||
_ = synchronizeHostFrameToReference()
|
||||
|
||||
// Seed frame/bounds before entering the window so a freshly reparented
|
||||
// surface doesn't do a transient 800x600 size update on viewDidMoveToWindow.
|
||||
if let seededFrame = seededFrameInHost(for: anchorView),
|
||||
seededFrame.width > 0,
|
||||
seededFrame.height > 0 {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.frame = seededFrame
|
||||
hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size)
|
||||
CATransaction.commit()
|
||||
} else {
|
||||
// If anchor geometry is still unsettled, keep this hidden/zero-sized until
|
||||
// synchronizeHostedView resolves a valid target frame on the next layout tick.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.frame = .zero
|
||||
hostedView.bounds = .zero
|
||||
CATransaction.commit()
|
||||
hostedView.isHidden = true
|
||||
}
|
||||
// Keep inner scroll/surface geometry in sync with the seeded outer frame
|
||||
// before the hosted view enters a window.
|
||||
hostedView.reconcileGeometryNow()
|
||||
|
||||
if hostedView.superview !== hostView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
|
|
@ -765,10 +1042,13 @@ final class WindowTerminalPortal: NSObject {
|
|||
ensureDividerOverlayOnTop()
|
||||
|
||||
synchronizeHostedView(withId: hostedId)
|
||||
scheduleDeferredFullSynchronizeAll()
|
||||
pruneDeadEntries()
|
||||
}
|
||||
|
||||
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
|
||||
guard ensureInstalled() else { return }
|
||||
synchronizeLayoutHierarchy()
|
||||
pruneDeadEntries()
|
||||
let anchorId = ObjectIdentifier(anchorView)
|
||||
let primaryHostedId = hostedByAnchorId[anchorId]
|
||||
|
|
@ -795,6 +1075,7 @@ final class WindowTerminalPortal: NSObject {
|
|||
|
||||
private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) {
|
||||
guard ensureInstalled() else { return }
|
||||
synchronizeLayoutHierarchy()
|
||||
pruneDeadEntries()
|
||||
let hostedIds = Array(entriesByHostedId.keys)
|
||||
for hostedId in hostedIds {
|
||||
|
|
@ -837,63 +1118,161 @@ final class WindowTerminalPortal: NSObject {
|
|||
return
|
||||
}
|
||||
|
||||
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
||||
let frameInHost = hostView.convert(frameInWindow, from: nil)
|
||||
_ = synchronizeHostFrameToReference()
|
||||
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
|
||||
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
|
||||
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
|
||||
#if DEBUG
|
||||
logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView)
|
||||
#endif
|
||||
let hostBounds = hostView.bounds
|
||||
let hasFiniteHostBounds =
|
||||
hostBounds.origin.x.isFinite &&
|
||||
hostBounds.origin.y.isFinite &&
|
||||
hostBounds.size.width.isFinite &&
|
||||
hostBounds.size.height.isFinite
|
||||
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
|
||||
if !hostBoundsReady {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"portal.sync.defer hosted=\(portalDebugToken(hostedView)) " +
|
||||
"reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " +
|
||||
"anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
hostedView.isHidden = true
|
||||
scheduleDeferredFullSynchronizeAll()
|
||||
return
|
||||
}
|
||||
let hasFiniteFrame =
|
||||
frameInHost.origin.x.isFinite &&
|
||||
frameInHost.origin.y.isFinite &&
|
||||
frameInHost.size.width.isFinite &&
|
||||
frameInHost.size.height.isFinite
|
||||
let clampedFrame = frameInHost.intersection(hostBounds)
|
||||
let hasVisibleIntersection =
|
||||
!clampedFrame.isNull &&
|
||||
clampedFrame.width > 1 &&
|
||||
clampedFrame.height > 1
|
||||
let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost
|
||||
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
|
||||
let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1
|
||||
let outsideHostBounds = !frameInHost.intersects(hostView.bounds)
|
||||
let tinyFrame =
|
||||
targetFrame.width <= Self.tinyHideThreshold ||
|
||||
targetFrame.height <= Self.tinyHideThreshold
|
||||
let revealReadyForDisplay =
|
||||
targetFrame.width >= Self.minimumRevealWidth &&
|
||||
targetFrame.height >= Self.minimumRevealHeight
|
||||
let outsideHostBounds = !hasVisibleIntersection
|
||||
let shouldHide =
|
||||
!entry.visibleInUI ||
|
||||
anchorHidden ||
|
||||
tinyFrame ||
|
||||
!hasFiniteFrame ||
|
||||
outsideHostBounds
|
||||
let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay
|
||||
|
||||
let oldFrame = hostedView.frame
|
||||
#if DEBUG
|
||||
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
|
||||
if frameWasClamped {
|
||||
dlog(
|
||||
"portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " +
|
||||
"anchor=\(portalDebugToken(anchorView)) " +
|
||||
"raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " +
|
||||
"host=\(portalDebugFrame(hostBounds))"
|
||||
)
|
||||
}
|
||||
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
|
||||
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
|
||||
if collapsedToTiny {
|
||||
dlog(
|
||||
"portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
|
||||
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
|
||||
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
|
||||
)
|
||||
} else if restoredFromTiny {
|
||||
dlog(
|
||||
"portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
|
||||
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
|
||||
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
|
||||
|
||||
// Hide before updating the frame when this entry should not be visible.
|
||||
// This avoids a one-frame flash of unrendered terminal background when a portal
|
||||
// briefly transitions through offscreen/tiny geometry during rapid split churn.
|
||||
if shouldHide, !hostedView.isHidden {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " +
|
||||
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
|
||||
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
|
||||
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
|
||||
"host=\(portalDebugFrame(hostBounds))"
|
||||
)
|
||||
#endif
|
||||
hostedView.isHidden = true
|
||||
}
|
||||
|
||||
if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.frame = frameInHost
|
||||
hostedView.frame = targetFrame
|
||||
CATransaction.commit()
|
||||
hostedView.reconcileGeometryNow()
|
||||
hostedView.refreshSurfaceNow()
|
||||
}
|
||||
|
||||
if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 ||
|
||||
abs(oldFrame.size.height - frameInHost.size.height) > 0.5 {
|
||||
hostedView.reconcileGeometryNow()
|
||||
if hasFiniteFrame {
|
||||
let expectedBounds = NSRect(origin: .zero, size: targetFrame.size)
|
||||
if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.bounds = expectedBounds
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
if hostedView.isHidden != shouldHide {
|
||||
if shouldDeferReveal {
|
||||
#if DEBUG
|
||||
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
|
||||
dlog(
|
||||
"portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " +
|
||||
"frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if !shouldHide, hostedView.isHidden, revealReadyForDisplay {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " +
|
||||
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " +
|
||||
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
|
||||
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
|
||||
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))"
|
||||
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
|
||||
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
|
||||
"host=\(portalDebugFrame(hostBounds))"
|
||||
)
|
||||
#endif
|
||||
hostedView.isHidden = shouldHide
|
||||
hostedView.isHidden = false
|
||||
// A reveal can happen without any frame delta (same targetFrame), which means the
|
||||
// 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()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"portal.sync.result hosted=\(portalDebugToken(hostedView)) " +
|
||||
"anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " +
|
||||
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
|
||||
"old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " +
|
||||
"target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
|
||||
"entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " +
|
||||
"hostBounds=\(portalDebugFrame(hostBounds))"
|
||||
)
|
||||
#endif
|
||||
|
||||
ensureDividerOverlayOnTop()
|
||||
}
|
||||
|
||||
|
|
@ -927,6 +1306,7 @@ final class WindowTerminalPortal: NSObject {
|
|||
}
|
||||
|
||||
func tearDown() {
|
||||
removeGeometryObservers()
|
||||
for hostedId in Array(entriesByHostedId.keys) {
|
||||
detachHostedView(withId: hostedId)
|
||||
}
|
||||
|
|
@ -1093,6 +1473,15 @@ enum TerminalWindowPortalRegistry {
|
|||
portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI)
|
||||
}
|
||||
|
||||
static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool {
|
||||
let hostedId = ObjectIdentifier(hostedView)
|
||||
guard let window = anchorView.window else { return false }
|
||||
let windowId = ObjectIdentifier(window)
|
||||
guard hostedToWindowId[hostedId] == windowId,
|
||||
let portal = portalsByWindowId[windowId] else { return false }
|
||||
return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView)
|
||||
}
|
||||
|
||||
static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? {
|
||||
let portal = portal(for: window)
|
||||
return portal.viewAtWindowPoint(windowPoint)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ class UpdateController {
|
|||
private(set) var updater: SPUUpdater
|
||||
private let userDriver: UpdateDriver
|
||||
private var installCancellable: AnyCancellable?
|
||||
private var attemptInstallCancellable: AnyCancellable?
|
||||
private var didObserveAttemptUpdateProgress: Bool = false
|
||||
private var noUpdateDismissCancellable: AnyCancellable?
|
||||
private var noUpdateDismissWorkItem: DispatchWorkItem?
|
||||
private var readyCheckWorkItem: DispatchWorkItem?
|
||||
|
|
@ -46,6 +48,7 @@ class UpdateController {
|
|||
|
||||
deinit {
|
||||
installCancellable?.cancel()
|
||||
attemptInstallCancellable?.cancel()
|
||||
noUpdateDismissCancellable?.cancel()
|
||||
noUpdateDismissWorkItem?.cancel()
|
||||
readyCheckWorkItem?.cancel()
|
||||
|
|
@ -107,6 +110,35 @@ class UpdateController {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check for updates and auto-confirm install if one is found.
|
||||
func attemptUpdate() {
|
||||
stopAttemptUpdateMonitoring()
|
||||
didObserveAttemptUpdateProgress = false
|
||||
|
||||
attemptInstallCancellable = viewModel.$state
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self else { return }
|
||||
|
||||
if state.isInstallable || !state.isIdle {
|
||||
self.didObserveAttemptUpdateProgress = true
|
||||
}
|
||||
|
||||
if case .updateAvailable = state {
|
||||
UpdateLogStore.shared.append("attemptUpdate auto-confirming available update")
|
||||
state.confirm()
|
||||
return
|
||||
}
|
||||
|
||||
guard self.didObserveAttemptUpdateProgress, !state.isInstallable else {
|
||||
return
|
||||
}
|
||||
self.stopAttemptUpdateMonitoring()
|
||||
}
|
||||
|
||||
checkForUpdates()
|
||||
}
|
||||
|
||||
/// Check for updates (used by the menu item).
|
||||
@objc func checkForUpdates() {
|
||||
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
|
||||
|
|
@ -175,6 +207,12 @@ class UpdateController {
|
|||
return true
|
||||
}
|
||||
|
||||
private func stopAttemptUpdateMonitoring() {
|
||||
attemptInstallCancellable?.cancel()
|
||||
attemptInstallCancellable = nil
|
||||
didObserveAttemptUpdateProgress = false
|
||||
}
|
||||
|
||||
private func installNoUpdateDismissObserver() {
|
||||
noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState)
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ extension UpdateDriver: SPUUpdaterDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
|
||||
AppDelegate.shared?.persistSessionForUpdateRelaunch()
|
||||
TerminalController.shared.stop()
|
||||
NSApp.invalidateRestorableState()
|
||||
for window in NSApp.windows {
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ struct TitlebarControlsView: View {
|
|||
.foregroundColor(.white)
|
||||
.frame(width: config.badgeSize, height: config.badgeSize)
|
||||
.background(
|
||||
Circle().fill(Color.accentColor)
|
||||
Circle().fill(cmuxAccentColor())
|
||||
)
|
||||
.offset(x: config.badgeOffset.width, y: config.badgeOffset.height)
|
||||
}
|
||||
|
|
@ -905,11 +905,11 @@ private struct NotificationPopoverRow: View {
|
|||
Button(action: onOpen) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(notification.isRead ? Color.clear : Color.accentColor)
|
||||
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
||||
.stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
||||
)
|
||||
.padding(.top, 6)
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ class UpdateViewModel: ObservableObject {
|
|||
case .checking:
|
||||
return .secondary
|
||||
case .updateAvailable:
|
||||
return .accentColor
|
||||
return cmuxAccentColor()
|
||||
case .downloading, .extracting, .installing:
|
||||
return .secondary
|
||||
case .notFound:
|
||||
|
|
@ -147,7 +147,7 @@ class UpdateViewModel: ObservableObject {
|
|||
case .permissionRequest:
|
||||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
|
||||
case .updateAvailable:
|
||||
return .accentColor
|
||||
return cmuxAccentColor()
|
||||
case .notFound:
|
||||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue)
|
||||
case .error:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,246 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import SwiftUI
|
||||
|
||||
private func windowDragHandleFormatPoint(_ point: NSPoint) -> String {
|
||||
String(format: "(%.1f,%.1f)", point.x, point.y)
|
||||
}
|
||||
|
||||
/// Runs the same action macOS titlebars use for double-click:
|
||||
/// zoom by default, or minimize when the user preference is set.
|
||||
@discardableResult
|
||||
func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
|
||||
guard let window else { return false }
|
||||
|
||||
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:]
|
||||
if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased() {
|
||||
switch action {
|
||||
case "minimize":
|
||||
window.miniaturize(nil)
|
||||
return true
|
||||
case "none":
|
||||
return false
|
||||
case "maximize", "zoom":
|
||||
window.zoom(nil)
|
||||
return true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
|
||||
miniaturizeOnDoubleClick {
|
||||
window.miniaturize(nil)
|
||||
return true
|
||||
}
|
||||
|
||||
window.zoom(nil)
|
||||
return true
|
||||
}
|
||||
|
||||
private var windowDragSuppressionDepthKey: UInt8 = 0
|
||||
|
||||
func beginWindowDragSuppression(window: NSWindow?) -> Int? {
|
||||
guard let window else { return nil }
|
||||
let current = windowDragSuppressionDepth(window: window)
|
||||
let next = current + 1
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
&windowDragSuppressionDepthKey,
|
||||
NSNumber(value: next),
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
return next
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func endWindowDragSuppression(window: NSWindow?) -> Int {
|
||||
guard let window else { return 0 }
|
||||
let current = windowDragSuppressionDepth(window: window)
|
||||
let next = max(0, current - 1)
|
||||
if next == 0 {
|
||||
objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
} else {
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
&windowDragSuppressionDepthKey,
|
||||
NSNumber(value: next),
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func windowDragSuppressionDepth(window: NSWindow?) -> Int {
|
||||
guard let window,
|
||||
let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else {
|
||||
return 0
|
||||
}
|
||||
return value.intValue
|
||||
}
|
||||
|
||||
func isWindowDragSuppressed(window: NSWindow?) -> Bool {
|
||||
windowDragSuppressionDepth(window: window) > 0
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func clearWindowDragSuppression(window: NSWindow?) -> Int {
|
||||
guard let window else { return 0 }
|
||||
var depth = windowDragSuppressionDepth(window: window)
|
||||
while depth > 0 {
|
||||
depth = endWindowDragSuppression(window: window)
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
/// Temporarily enables window movability for explicit drag-handle drags, then
|
||||
/// restores the previous movability state after `body` finishes.
|
||||
@discardableResult
|
||||
func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? {
|
||||
guard let window else {
|
||||
body()
|
||||
return nil
|
||||
}
|
||||
|
||||
let previousMovableState = window.isMovable
|
||||
if !previousMovableState {
|
||||
window.isMovable = true
|
||||
}
|
||||
defer {
|
||||
if window.isMovable != previousMovableState {
|
||||
window.isMovable = previousMovableState
|
||||
}
|
||||
}
|
||||
|
||||
body()
|
||||
return previousMovableState
|
||||
}
|
||||
|
||||
private enum WindowDragHandleHitTestState {
|
||||
static var isResolvingTopHit = false
|
||||
}
|
||||
|
||||
/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty
|
||||
/// titlebar space. Treat those as pass-through so explicit sibling checks decide.
|
||||
func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool {
|
||||
let className = String(describing: type(of: view))
|
||||
if className.contains("HostContainerView")
|
||||
|| className.contains("AppKitWindowHostingView")
|
||||
|| className.contains("NSHostingView") {
|
||||
return true
|
||||
}
|
||||
if let window = view.window, view === window.contentView {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns whether the titlebar drag handle should capture a hit at `point`.
|
||||
/// We only claim the hit when no sibling view already handles it, so interactive
|
||||
/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures.
|
||||
func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool {
|
||||
if isWindowDragSuppressed(window: dragHandleView.window) {
|
||||
// Recover from stale suppression if a prior interaction missed cleanup.
|
||||
// We only keep suppression active while the left mouse button is down.
|
||||
if (NSEvent.pressedMouseButtons & 0x1) == 0 {
|
||||
let clearedDepth = clearWindowDragSuppression(window: dragHandleView.window)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))"
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
let depth = windowDragSuppressionDepth(window: dragHandleView.window)
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
guard dragHandleView.bounds.contains(point) else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
guard let superview = dragHandleView.superview else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
if let window = dragHandleView.window,
|
||||
let contentView = window.contentView,
|
||||
!WindowDragHandleHitTestState.isResolvingTopHit {
|
||||
let pointInWindow = dragHandleView.convert(point, to: nil)
|
||||
let pointInContent = contentView.convert(pointInWindow, from: nil)
|
||||
|
||||
WindowDragHandleHitTestState.isResolvingTopHit = true
|
||||
let topHit = contentView.hitTest(pointInContent)
|
||||
WindowDragHandleHitTestState.isResolvingTopHit = false
|
||||
|
||||
if let topHit {
|
||||
let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView)
|
||||
let topHitBelongsToTitlebarOverlay = topHit === superview || topHit.isDescendant(of: superview)
|
||||
let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) inTitlebarOverlay=\(topHitBelongsToTitlebarOverlay) passiveHost=\(isPassiveHostHit)"
|
||||
)
|
||||
#endif
|
||||
if ownsTopHit {
|
||||
return true
|
||||
}
|
||||
// Underlay content can transiently overlap titlebar space (notably browser
|
||||
// chrome/webview layers). Only let top-hits block capture when they belong
|
||||
// to this titlebar overlay stack.
|
||||
if topHitBelongsToTitlebarOverlay && !isPassiveHostHit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let siblingCount = superview.subviews.count
|
||||
#endif
|
||||
|
||||
for sibling in superview.subviews.reversed() {
|
||||
guard sibling !== dragHandleView else { continue }
|
||||
guard !sibling.isHidden, sibling.alphaValue > 0 else { continue }
|
||||
|
||||
let pointInSibling = dragHandleView.convert(point, to: sibling)
|
||||
if let hitView = sibling.hitTest(pointInSibling) {
|
||||
let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView)
|
||||
if passiveHostHit {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true"
|
||||
)
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
/// A transparent view that enables dragging the window when clicking in empty titlebar space.
|
||||
/// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content
|
||||
/// (e.g. sidebar tab reordering) don't move the whole window.
|
||||
|
|
@ -14,8 +254,55 @@ struct WindowDragHandleView: NSViewRepresentable {
|
|||
}
|
||||
|
||||
private final class DraggableView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { true }
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { self }
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)"
|
||||
)
|
||||
#endif
|
||||
return shouldCapture ? self : nil
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let depth = windowDragSuppressionDepth(window: window)
|
||||
dlog(
|
||||
"titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)"
|
||||
)
|
||||
#endif
|
||||
|
||||
if event.clickCount >= 2 {
|
||||
let handled = performStandardTitlebarDoubleClick(window: window)
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)")
|
||||
#endif
|
||||
if handled {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard !isWindowDragSuppressed(window: window) else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
if let window {
|
||||
let previousMovableState = withTemporaryWindowMovableEnabled(window: window) {
|
||||
window.performDrag(with: event)
|
||||
}
|
||||
#if DEBUG
|
||||
let restored = previousMovableState.map { String($0) } ?? "nil"
|
||||
dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)")
|
||||
#endif
|
||||
} else {
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,10 +9,27 @@ struct WorkspaceContentView: View {
|
|||
let isWorkspaceVisible: Bool
|
||||
let isWorkspaceInputActive: Bool
|
||||
let workspacePortalPriority: Int
|
||||
@State private var config = GhosttyConfig.load()
|
||||
let onThemeRefreshRequest: ((
|
||||
_ reason: String,
|
||||
_ backgroundEventId: UInt64?,
|
||||
_ backgroundSource: String?,
|
||||
_ notificationPayloadHex: String?
|
||||
) -> Void)?
|
||||
@State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit")
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
|
||||
static func panelVisibleInUI(
|
||||
isWorkspaceVisible: Bool,
|
||||
isSelectedInPane: Bool,
|
||||
isFocused: Bool
|
||||
) -> Bool {
|
||||
guard isWorkspaceVisible else { return false }
|
||||
// During pane/tab reparenting, Bonsplit can transiently report selected=false
|
||||
// for the currently focused panel. Keep focused content visible to avoid blank frames.
|
||||
return isSelectedInPane || isFocused
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let appearance = PanelAppearance.fromConfig(config)
|
||||
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
|
||||
|
|
@ -41,7 +58,11 @@ struct WorkspaceContentView: View {
|
|||
if let panel = workspace.panel(for: tab.id) {
|
||||
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
|
||||
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
|
||||
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
|
||||
let isVisibleInUI = Self.panelVisibleInUI(
|
||||
isWorkspaceVisible: isWorkspaceVisible,
|
||||
isSelectedInPane: isSelectedInPane,
|
||||
isFocused: isFocused
|
||||
)
|
||||
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
|
||||
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
|
||||
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
|
||||
|
|
@ -61,7 +82,7 @@ struct WorkspaceContentView: View {
|
|||
// indicator and where keyboard input/flash-focus actually lands.
|
||||
guard isWorkspaceInputActive else { return }
|
||||
guard workspace.panels[panel.id] != nil else { return }
|
||||
workspace.focusPanel(panel.id)
|
||||
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
|
||||
},
|
||||
onRequestPanelFocus: {
|
||||
guard isWorkspaceInputActive else { return }
|
||||
|
|
@ -87,7 +108,7 @@ struct WorkspaceContentView: View {
|
|||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
syncBonsplitNotificationBadges()
|
||||
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor)
|
||||
refreshGhosttyAppearanceConfig(reason: "onAppear")
|
||||
}
|
||||
.onChange(of: notificationStore.notifications) { _, _ in
|
||||
syncBonsplitNotificationBadges()
|
||||
|
|
@ -96,18 +117,28 @@ struct WorkspaceContentView: View {
|
|||
syncBonsplitNotificationBadges()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
|
||||
refreshGhosttyAppearanceConfig()
|
||||
refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload")
|
||||
}
|
||||
.onChange(of: colorScheme) { _, _ in
|
||||
.onChange(of: colorScheme) { oldValue, newValue in
|
||||
// Keep split overlay color/opacity in sync with light/dark theme transitions.
|
||||
refreshGhosttyAppearanceConfig()
|
||||
refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in
|
||||
if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor {
|
||||
workspace.applyGhosttyChrome(backgroundColor: backgroundColor)
|
||||
} else {
|
||||
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor)
|
||||
}
|
||||
let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
|
||||
let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value
|
||||
let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil"
|
||||
logTheme(
|
||||
"theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
|
||||
)
|
||||
// Payload ordering can lag across rapid config/theme updates.
|
||||
// Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned
|
||||
// with Ghostty's current runtime theme.
|
||||
refreshGhosttyAppearanceConfig(
|
||||
reason: "ghosttyDefaultBackgroundDidChange",
|
||||
backgroundEventId: eventId,
|
||||
backgroundSource: source,
|
||||
notificationPayloadHex: payloadHex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,10 +172,95 @@ struct WorkspaceContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func refreshGhosttyAppearanceConfig() {
|
||||
let next = GhosttyConfig.load()
|
||||
config = next
|
||||
workspace.applyGhosttyChrome(from: next)
|
||||
static func resolveGhosttyAppearanceConfig(
|
||||
reason: String = "unspecified",
|
||||
backgroundOverride: NSColor? = nil,
|
||||
loadConfig: () -> GhosttyConfig = GhosttyConfig.load,
|
||||
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }
|
||||
) -> GhosttyConfig {
|
||||
var next = loadConfig()
|
||||
let loadedBackgroundHex = next.backgroundColor.hexString()
|
||||
let defaultBackgroundHex: String
|
||||
let resolvedBackground: NSColor
|
||||
|
||||
if let backgroundOverride {
|
||||
resolvedBackground = backgroundOverride
|
||||
defaultBackgroundHex = "skipped"
|
||||
} else {
|
||||
let fallback = defaultBackground()
|
||||
resolvedBackground = fallback
|
||||
defaultBackgroundHex = fallback.hexString()
|
||||
}
|
||||
|
||||
next.backgroundColor = resolvedBackground
|
||||
if GhosttyApp.shared.backgroundLogEnabled {
|
||||
GhosttyApp.shared.logBackground(
|
||||
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")"
|
||||
)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private func refreshGhosttyAppearanceConfig(
|
||||
reason: String,
|
||||
backgroundOverride: NSColor? = nil,
|
||||
backgroundEventId: UInt64? = nil,
|
||||
backgroundSource: String? = nil,
|
||||
notificationPayloadHex: String? = nil
|
||||
) {
|
||||
let previousBackgroundHex = config.backgroundColor.hexString()
|
||||
let next = Self.resolveGhosttyAppearanceConfig(
|
||||
reason: reason,
|
||||
backgroundOverride: backgroundOverride
|
||||
)
|
||||
let eventLabel = backgroundEventId.map(String.init) ?? "nil"
|
||||
let sourceLabel = backgroundSource ?? "nil"
|
||||
let payloadLabel = notificationPayloadHex ?? "nil"
|
||||
let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString()
|
||||
let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear"
|
||||
logTheme(
|
||||
"theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")"
|
||||
)
|
||||
withTransaction(Transaction(animation: nil)) {
|
||||
config = next
|
||||
if shouldRequestTitlebarRefresh {
|
||||
onThemeRefreshRequest?(
|
||||
reason,
|
||||
backgroundEventId,
|
||||
backgroundSource,
|
||||
notificationPayloadHex
|
||||
)
|
||||
}
|
||||
}
|
||||
if !shouldRequestTitlebarRefresh {
|
||||
logTheme(
|
||||
"theme refresh titlebar-skip workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString())"
|
||||
)
|
||||
}
|
||||
logTheme(
|
||||
"theme refresh config-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) configBg=\(config.backgroundColor.hexString())"
|
||||
)
|
||||
let chromeReason =
|
||||
"refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)"
|
||||
workspace.applyGhosttyChrome(from: next, reason: chromeReason)
|
||||
if let terminalPanel = workspace.focusedTerminalPanel {
|
||||
terminalPanel.applyWindowBackgroundIfActive()
|
||||
logTheme(
|
||||
"theme refresh terminal-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) panel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
|
||||
)
|
||||
} else {
|
||||
logTheme(
|
||||
"theme refresh terminal-skipped workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) focusedPanel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
|
||||
)
|
||||
}
|
||||
logTheme(
|
||||
"theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")"
|
||||
)
|
||||
}
|
||||
|
||||
private func logTheme(_ message: String) {
|
||||
guard GhosttyApp.shared.backgroundLogEnabled else { return }
|
||||
GhosttyApp.shared.logBackground(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ struct cmuxApp: App {
|
|||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
init() {
|
||||
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
|
||||
Self.terminateForMissingLaunchTag()
|
||||
}
|
||||
|
||||
Self.configureGhosttyEnvironment()
|
||||
|
||||
let startupAppearance = AppearanceSettings.resolvedMode()
|
||||
|
|
@ -58,6 +62,14 @@ struct cmuxApp: App {
|
|||
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
|
||||
}
|
||||
|
||||
private static func terminateForMissingLaunchTag() -> Never {
|
||||
let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag <name> (or set CMUX_TAG for test harnesses)"
|
||||
fputs("\(message)\n", stderr)
|
||||
fflush(stderr)
|
||||
NSLog("%@", message)
|
||||
Darwin.exit(64)
|
||||
}
|
||||
|
||||
private static func configureGhosttyEnvironment() {
|
||||
let fileManager = FileManager.default
|
||||
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
|
||||
|
|
@ -211,7 +223,7 @@ struct cmuxApp: App {
|
|||
GhosttyApp.shared.openConfigurationInTextEdit()
|
||||
}
|
||||
Button("Reload Configuration") {
|
||||
GhosttyApp.shared.reloadConfiguration()
|
||||
GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration")
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: [.command, .shift])
|
||||
Divider()
|
||||
|
|
@ -357,12 +369,37 @@ struct cmuxApp: App {
|
|||
}
|
||||
|
||||
splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).addTab()
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil {
|
||||
#if DEBUG
|
||||
FocusLogStore.shared.append(
|
||||
"cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil"
|
||||
)
|
||||
#endif
|
||||
appDelegate.openNewMainWindow(nil)
|
||||
}
|
||||
} else {
|
||||
activeTabManager.addTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close tab/workspace
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Go to Workspace or Tab…") {
|
||||
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: [.command])
|
||||
|
||||
Button("Command Palette…") {
|
||||
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow)
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: [.command, .shift])
|
||||
|
||||
Divider()
|
||||
|
||||
// Terminal semantics:
|
||||
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
|
||||
// tab in the last workspace, it closes the window.
|
||||
|
|
@ -378,7 +415,7 @@ struct cmuxApp: App {
|
|||
}
|
||||
|
||||
Button("Reopen Closed Browser Panel") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel()
|
||||
_ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
|
||||
}
|
||||
.keyboardShortcut("t", modifiers: [.command, .shift])
|
||||
}
|
||||
|
|
@ -387,95 +424,97 @@ struct cmuxApp: App {
|
|||
CommandGroup(after: .textEditing) {
|
||||
Menu("Find") {
|
||||
Button("Find…") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).startSearch()
|
||||
activeTabManager.startSearch()
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
|
||||
Button("Find Next") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).findNext()
|
||||
activeTabManager.findNext()
|
||||
}
|
||||
.keyboardShortcut("g", modifiers: .command)
|
||||
|
||||
Button("Find Previous") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).findPrevious()
|
||||
activeTabManager.findPrevious()
|
||||
}
|
||||
.keyboardShortcut("g", modifiers: [.command, .shift])
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Hide Find Bar") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).hideFind()
|
||||
activeTabManager.hideFind()
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: [.command, .shift])
|
||||
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible))
|
||||
.disabled(!(activeTabManager.isFindVisible))
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Use Selection for Find") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).searchSelection()
|
||||
activeTabManager.searchSelection()
|
||||
}
|
||||
.keyboardShortcut("e", modifiers: .command)
|
||||
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind))
|
||||
.disabled(!(activeTabManager.canUseSelectionForFind))
|
||||
}
|
||||
}
|
||||
|
||||
// Tab navigation
|
||||
CommandGroup(after: .toolbar) {
|
||||
splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) {
|
||||
sidebarState.toggle()
|
||||
if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true {
|
||||
sidebarState.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface()
|
||||
activeTabManager.selectNextSurface()
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface()
|
||||
activeTabManager.selectPreviousSurface()
|
||||
}
|
||||
|
||||
Button("Back") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack()
|
||||
activeTabManager.focusedBrowserPanel?.goBack()
|
||||
}
|
||||
.keyboardShortcut("[", modifiers: .command)
|
||||
|
||||
Button("Forward") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward()
|
||||
activeTabManager.focusedBrowserPanel?.goForward()
|
||||
}
|
||||
.keyboardShortcut("]", modifiers: .command)
|
||||
|
||||
Button("Reload Page") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload()
|
||||
activeTabManager.focusedBrowserPanel?.reload()
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
|
||||
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
let manager = activeTabManager
|
||||
if !manager.toggleDeveloperToolsFocusedBrowser() {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
let manager = activeTabManager
|
||||
if !manager.showJavaScriptConsoleFocusedBrowser() {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
|
||||
Button("Zoom In") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser()
|
||||
_ = activeTabManager.zoomInFocusedBrowser()
|
||||
}
|
||||
.keyboardShortcut("=", modifiers: .command)
|
||||
|
||||
Button("Zoom Out") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser()
|
||||
_ = activeTabManager.zoomOutFocusedBrowser()
|
||||
}
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
|
||||
Button("Actual Size") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser()
|
||||
_ = activeTabManager.resetZoomFocusedBrowser()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: .command)
|
||||
|
||||
|
|
@ -484,11 +523,11 @@ struct cmuxApp: App {
|
|||
}
|
||||
|
||||
splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
|
||||
activeTabManager.selectNextTab()
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab()
|
||||
activeTabManager.selectPreviousTab()
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) {
|
||||
|
|
@ -518,7 +557,7 @@ struct cmuxApp: App {
|
|||
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
|
||||
ForEach(1...9, id: \.self) { number in
|
||||
Button("Workspace \(number)") {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
let manager = activeTabManager
|
||||
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
|
||||
manager.selectTab(at: targetIndex)
|
||||
}
|
||||
|
|
@ -689,6 +728,12 @@ struct cmuxApp: App {
|
|||
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
|
||||
}
|
||||
|
||||
private var activeTabManager: TabManager {
|
||||
AppDelegate.shared?.synchronizeActiveMainWindowContext(
|
||||
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
|
||||
) ?? tabManager
|
||||
}
|
||||
|
||||
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
||||
guard !data.isEmpty,
|
||||
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
||||
|
|
@ -740,11 +785,11 @@ struct cmuxApp: App {
|
|||
window.performClose(nil)
|
||||
return
|
||||
}
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation()
|
||||
activeTabManager.closeCurrentPanelWithConfirmation()
|
||||
}
|
||||
|
||||
private func closeTabOrWindow() {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation()
|
||||
activeTabManager.closeCurrentTabWithConfirmation()
|
||||
}
|
||||
|
||||
private func showNotificationsPopover() {
|
||||
|
|
@ -2533,6 +2578,18 @@ enum QuitWarningSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum CommandPaletteRenameSelectionSettings {
|
||||
static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus"
|
||||
static let defaultSelectAllOnFocus = true
|
||||
|
||||
static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: selectAllOnFocusKey) == nil {
|
||||
return defaultSelectAllOnFocus
|
||||
}
|
||||
return defaults.bool(forKey: selectAllOnFocusKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum ClaudeCodeIntegrationSettings {
|
||||
static let hooksEnabledKey = "claudeCodeHooksEnabled"
|
||||
static let defaultHooksEnabled = true
|
||||
|
|
@ -2559,10 +2616,14 @@ struct SettingsView: View {
|
|||
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
|
||||
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
||||
@AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
|
||||
private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue()
|
||||
@AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
|
||||
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
|
|
@ -2765,6 +2826,19 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Rename Selects Existing Name",
|
||||
subtitle: commandPaletteRenameSelectAllOnFocus
|
||||
? "Command Palette rename starts with all text selected."
|
||||
: "Command Palette rename keeps the caret at the end."
|
||||
) {
|
||||
Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Sidebar Branch Layout",
|
||||
subtitle: sidebarBranchVerticalLayout
|
||||
|
|
@ -3094,13 +3168,24 @@ struct SettingsView: View {
|
|||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if openTerminalLinksInCmuxBrowser {
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Intercept open http(s) in Terminal",
|
||||
subtitle: "When off, `open https://...` and `open http://...` always use your default browser."
|
||||
) {
|
||||
Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser {
|
||||
SettingsCardDivider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SettingsCardRow(
|
||||
"Hosts to Open in Embedded Browser",
|
||||
subtitle: "When you click links in terminal output, only these hosts open in cmux. Other hosts open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all links in cmux."
|
||||
subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux."
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
|
|
@ -3362,11 +3447,13 @@ struct SettingsView: View {
|
|||
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
|
||||
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
||||
interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser
|
||||
browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
|
||||
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
|
|
|
|||
450
cmuxTests/AppDelegateShortcutRoutingTests.swift
Normal file
450
cmuxTests/AppDelegateShortcutRoutingTests.swift
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AppDelegateShortcutRoutingTests: XCTestCase {
|
||||
func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
|
||||
XCTAssertTrue(appDelegate.focusMainWindow(windowId: firstWindowId))
|
||||
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: secondWindow.windowNumber,
|
||||
context: nil,
|
||||
characters: "n",
|
||||
charactersIgnoringModifiers: "n",
|
||||
isARepeat: false,
|
||||
keyCode: 45
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+N event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not add workspace to stale active window")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should add workspace to the event's window")
|
||||
}
|
||||
|
||||
func testAddWorkspaceInPreferredMainWindowIgnoresStaleTabManagerPointer() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
// Force a stale app-level pointer to a different manager.
|
||||
appDelegate.tabManager = firstManager
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager)
|
||||
|
||||
_ = appDelegate.addWorkspaceInPreferredMainWindow()
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Stale pointer must not receive menu-driven workspace creation")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Workspace creation should target key/main window context")
|
||||
}
|
||||
|
||||
func testCmdNResolvesEventWindowWhenObjectKeyLookupIsMismatched() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId))
|
||||
#else
|
||||
XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
// Ensure stale active-manager pointer does not mask routing errors.
|
||||
appDelegate.tabManager = firstManager
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: secondWindow.windowNumber,
|
||||
context: nil,
|
||||
characters: "n",
|
||||
charactersIgnoringModifiers: "n",
|
||||
isARepeat: false,
|
||||
keyCode: 45
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+N event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not route to another window when object-key lookup misses")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should still route by event window metadata when object-key lookup misses")
|
||||
}
|
||||
|
||||
func testAddWorkspaceInPreferredMainWindowUsesKeyWindowWhenObjectKeyLookupIsMismatched() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId))
|
||||
#else
|
||||
XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
// Stale pointer should not receive the new workspace.
|
||||
appDelegate.tabManager = firstManager
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
|
||||
_ = appDelegate.addWorkspaceInPreferredMainWindow()
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Menu-driven add workspace should not route to stale window")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses")
|
||||
}
|
||||
|
||||
func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
_ = firstManager.addTab(select: true)
|
||||
_ = secondManager.addTab(select: true)
|
||||
|
||||
guard let firstSelectedBefore = firstManager.selectedTabId,
|
||||
let secondSelectedBefore = secondManager.selectedTabId else {
|
||||
XCTFail("Expected selected tabs in both windows")
|
||||
return
|
||||
}
|
||||
guard let secondFirstTabId = secondManager.tabs.first?.id else {
|
||||
XCTFail("Expected at least one tab in second window")
|
||||
return
|
||||
}
|
||||
|
||||
appDelegate.tabManager = firstManager
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager)
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "1",
|
||||
modifiers: [.command],
|
||||
keyCode: 18, // kVK_ANSI_1
|
||||
windowNumber: secondWindow.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+1 event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Cmd+1 must not select a tab in stale active window")
|
||||
XCTAssertNotEqual(secondManager.selectedTabId, secondSelectedBefore, "Cmd+1 should change tab selection in event window")
|
||||
XCTAssertEqual(secondManager.selectedTabId, secondFirstTabId, "Cmd+1 should select first tab in the event window")
|
||||
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
|
||||
}
|
||||
|
||||
func testCmdTRoutesToEventWindowWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId),
|
||||
let firstWorkspace = firstManager.selectedWorkspace,
|
||||
let secondWorkspace = secondManager.selectedWorkspace else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
let firstSurfaceCount = firstWorkspace.panels.count
|
||||
let secondSurfaceCount = secondWorkspace.panels.count
|
||||
|
||||
appDelegate.tabManager = firstManager
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager)
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "t",
|
||||
modifiers: [.command],
|
||||
keyCode: 17, // kVK_ANSI_T
|
||||
windowNumber: secondWindow.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+T event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+T must not create a surface in stale active window")
|
||||
XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+T should create a surface in the event window")
|
||||
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
|
||||
}
|
||||
|
||||
func testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
_ = firstManager.addTab(select: true)
|
||||
_ = secondManager.addTab(select: true)
|
||||
guard let firstSelectedBefore = firstManager.selectedTabId,
|
||||
let secondSelectedBefore = secondManager.selectedTabId else {
|
||||
XCTFail("Expected selected tabs in both windows")
|
||||
return
|
||||
}
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
// Force stale app-level manager to first window while keyboard event
|
||||
// references no known window.
|
||||
appDelegate.tabManager = firstManager
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "1",
|
||||
modifiers: [.command],
|
||||
keyCode: 18,
|
||||
windowNumber: Int.max
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+1 event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Unresolved event window must not route Cmd+1 into stale manager")
|
||||
XCTAssertEqual(secondManager.selectedTabId, secondSelectedBefore, "Unresolved event window must not route Cmd+1 into key/main fallback manager")
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
|
||||
}
|
||||
|
||||
func testCmdNDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
appDelegate.tabManager = firstManager
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "n",
|
||||
modifiers: [.command],
|
||||
keyCode: 45,
|
||||
windowNumber: Int.max
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+N event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Unresolved event window must not create workspace in stale manager")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount, "Unresolved event window must not create workspace in fallback window")
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
|
||||
}
|
||||
|
||||
private func makeKeyDownEvent(
|
||||
key: String,
|
||||
modifiers: NSEvent.ModifierFlags,
|
||||
keyCode: UInt16,
|
||||
windowNumber: Int
|
||||
) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: modifiers,
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: windowNumber,
|
||||
context: nil,
|
||||
characters: key,
|
||||
charactersIgnoringModifiers: key,
|
||||
isARepeat: false,
|
||||
keyCode: keyCode
|
||||
)
|
||||
}
|
||||
|
||||
private func window(withId windowId: UUID) -> NSWindow? {
|
||||
let identifier = "cmux.main.\(windowId.uuidString)"
|
||||
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
|
||||
}
|
||||
|
||||
private func closeWindow(withId windowId: UUID) {
|
||||
guard let window = window(withId: windowId) else { return }
|
||||
window.performClose(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -162,6 +162,69 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() {
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .unscoped,
|
||||
incomingScope: .app
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .app,
|
||||
incomingScope: .surface
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .surface,
|
||||
incomingScope: .surface
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .surface,
|
||||
incomingScope: .app
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .surface,
|
||||
incomingScope: .unscoped
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testAppearanceChangeReloadsWhenColorSchemeChanges() {
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
||||
previousColorScheme: .dark,
|
||||
currentColorScheme: .light
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
||||
previousColorScheme: nil,
|
||||
currentColorScheme: .dark
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() {
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
||||
previousColorScheme: .light,
|
||||
currentColorScheme: .light
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
||||
previousColorScheme: .dark,
|
||||
currentColorScheme: .dark
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() {
|
||||
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
|
|
@ -208,6 +271,75 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class WorkspaceChromeThemeTests: XCTestCase {
|
||||
func testResolvedChromeColorsUsesLightGhosttyBackground() {
|
||||
guard let backgroundColor = NSColor(hex: "#FDF6E3") else {
|
||||
XCTFail("Expected valid test color")
|
||||
return
|
||||
}
|
||||
|
||||
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
|
||||
XCTAssertEqual(colors.backgroundHex, "#FDF6E3")
|
||||
XCTAssertNil(colors.borderHex)
|
||||
}
|
||||
|
||||
func testResolvedChromeColorsUsesDarkGhosttyBackground() {
|
||||
guard let backgroundColor = NSColor(hex: "#272822") else {
|
||||
XCTFail("Expected valid test color")
|
||||
return
|
||||
}
|
||||
|
||||
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
|
||||
XCTAssertEqual(colors.backgroundHex, "#272822")
|
||||
XCTAssertNil(colors.borderHex)
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceAppearanceConfigResolutionTests: XCTestCase {
|
||||
func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() {
|
||||
guard let loadedBackground = NSColor(hex: "#112233"),
|
||||
let runtimeBackground = NSColor(hex: "#FDF6E3"),
|
||||
let loadedForeground = NSColor(hex: "#ABCDEF") else {
|
||||
XCTFail("Expected valid test colors")
|
||||
return
|
||||
}
|
||||
|
||||
var loaded = GhosttyConfig()
|
||||
loaded.backgroundColor = loadedBackground
|
||||
loaded.foregroundColor = loadedForeground
|
||||
loaded.unfocusedSplitOpacity = 0.42
|
||||
|
||||
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
|
||||
loadConfig: { loaded },
|
||||
defaultBackground: { runtimeBackground }
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3")
|
||||
XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF")
|
||||
XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() {
|
||||
guard let loadedBackground = NSColor(hex: "#112233"),
|
||||
let runtimeBackground = NSColor(hex: "#FDF6E3"),
|
||||
let explicitOverride = NSColor(hex: "#272822") else {
|
||||
XCTFail("Expected valid test colors")
|
||||
return
|
||||
}
|
||||
|
||||
var loaded = GhosttyConfig()
|
||||
loaded.backgroundColor = loadedBackground
|
||||
|
||||
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
|
||||
backgroundOverride: explicitOverride,
|
||||
loadConfig: { loaded },
|
||||
defaultBackground: { runtimeBackground }
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822")
|
||||
}
|
||||
}
|
||||
|
||||
final class NotificationBurstCoalescerTests: XCTestCase {
|
||||
func testSignalsInSameBurstFlushOnce() {
|
||||
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
||||
|
|
@ -271,6 +403,95 @@ final class NotificationBurstCoalescerTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase {
|
||||
func testSignalCoalescesBurstToLatestBackground() {
|
||||
guard let dark = NSColor(hex: "#272822"),
|
||||
let light = NSColor(hex: "#FDF6E3") else {
|
||||
XCTFail("Expected valid test colors")
|
||||
return
|
||||
}
|
||||
|
||||
let expectation = expectation(description: "coalesced notification")
|
||||
expectation.expectedFulfillmentCount = 1
|
||||
var postedUserInfos: [[AnyHashable: Any]] = []
|
||||
|
||||
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
||||
delay: 0.01,
|
||||
postNotification: { userInfo in
|
||||
postedUserInfos.append(userInfo)
|
||||
expectation.fulfill()
|
||||
}
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark")
|
||||
dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light")
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
XCTAssertEqual(postedUserInfos.count, 1)
|
||||
XCTAssertEqual(
|
||||
(postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(),
|
||||
"#FDF6E3"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]),
|
||||
0.75,
|
||||
accuracy: 0.0001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
(postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value,
|
||||
2
|
||||
)
|
||||
XCTAssertEqual(
|
||||
postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String,
|
||||
"test.light"
|
||||
)
|
||||
}
|
||||
|
||||
func testSignalAcrossSeparateBurstsPostsMultipleNotifications() {
|
||||
guard let dark = NSColor(hex: "#272822"),
|
||||
let light = NSColor(hex: "#FDF6E3") else {
|
||||
XCTFail("Expected valid test colors")
|
||||
return
|
||||
}
|
||||
|
||||
let expectation = expectation(description: "two notifications")
|
||||
expectation.expectedFulfillmentCount = 2
|
||||
var postedHexes: [String] = []
|
||||
|
||||
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
||||
delay: 0.01,
|
||||
postNotification: { userInfo in
|
||||
let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
|
||||
postedHexes.append(hex)
|
||||
expectation.fulfill()
|
||||
}
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light")
|
||||
}
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"])
|
||||
}
|
||||
|
||||
private func postedOpacity(from value: Any?) -> Double {
|
||||
if let value = value as? Double {
|
||||
return value
|
||||
}
|
||||
if let value = value as? NSNumber {
|
||||
return value.doubleValue
|
||||
}
|
||||
XCTFail("Expected background opacity payload")
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
final class RecentlyClosedBrowserStackTests: XCTestCase {
|
||||
func testPopReturnsEntriesInLIFOOrder() {
|
||||
var stack = RecentlyClosedBrowserStack(capacity: 20)
|
||||
|
|
@ -470,6 +691,56 @@ final class SocketControlSettingsTests: XCTestCase {
|
|||
"/tmp/cmux-staging.sock"
|
||||
)
|
||||
}
|
||||
|
||||
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
|
||||
XCTAssertTrue(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testUntaggedDebugBundleAllowedWithLaunchTag() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["CMUX_TAG": "tests-v1"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testTaggedDebugBundleAllowedWithoutLaunchTag() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug.tests-v1",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testReleaseBuildIgnoresLaunchTagGate() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testXCTestLaunchIgnoresLaunchTagGate() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class PostHogAnalyticsPropertiesTests: XCTestCase {
|
||||
|
|
|
|||
645
cmuxTests/SessionPersistenceTests.swift
Normal file
645
cmuxTests/SessionPersistenceTests.swift
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class SessionPersistenceTests: XCTestCase {
|
||||
func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
|
||||
let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
|
||||
|
||||
XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
|
||||
|
||||
let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
|
||||
XCTAssertNotNil(loaded)
|
||||
XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion)
|
||||
XCTAssertEqual(loaded?.windows.count, 1)
|
||||
XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs)
|
||||
let frame = try XCTUnwrap(loaded?.windows.first?.frame)
|
||||
XCTAssertEqual(frame.x, 10, accuracy: 0.001)
|
||||
XCTAssertEqual(frame.y, 20, accuracy: 0.001)
|
||||
XCTAssertEqual(frame.width, 900, accuracy: 0.001)
|
||||
XCTAssertEqual(frame.height, 700, accuracy: 0.001)
|
||||
XCTAssertEqual(loaded?.windows.first?.display?.displayID, 42)
|
||||
let visibleFrame = try XCTUnwrap(loaded?.windows.first?.display?.visibleFrame)
|
||||
XCTAssertEqual(visibleFrame.y, 25, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
|
||||
var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
|
||||
snapshot.windows[0].tabManager.workspaces[0].customColor = "#C0392B"
|
||||
|
||||
XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
|
||||
|
||||
let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
|
||||
XCTAssertEqual(
|
||||
loaded?.windows.first?.tabManager.workspaces.first?.customColor,
|
||||
"#C0392B"
|
||||
)
|
||||
}
|
||||
|
||||
func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws {
|
||||
var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
|
||||
snapshot.windows[0].tabManager.workspaces[0].customColor = nil
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(snapshot)
|
||||
let json = try XCTUnwrap(String(data: data, encoding: .utf8))
|
||||
XCTAssertFalse(json.contains("\"customColor\""))
|
||||
|
||||
let decoded = try JSONDecoder().decode(AppSessionSnapshot.self, from: data)
|
||||
XCTAssertNil(decoded.windows.first?.tabManager.workspaces.first?.customColor)
|
||||
}
|
||||
|
||||
func testLoadRejectsSchemaVersionMismatch() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
|
||||
XCTAssertTrue(SessionPersistenceStore.save(makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1), fileURL: snapshotURL))
|
||||
|
||||
XCTAssertNil(SessionPersistenceStore.load(fileURL: snapshotURL))
|
||||
}
|
||||
|
||||
func testDefaultSnapshotPathSanitizesBundleIdentifier() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let path = SessionPersistenceStore.defaultSnapshotFileURL(
|
||||
bundleIdentifier: "com.example/unsafe id",
|
||||
appSupportDirectory: tempDir
|
||||
)
|
||||
|
||||
XCTAssertNotNil(path)
|
||||
XCTAssertTrue(path?.path.contains("com.example_unsafe_id") == true)
|
||||
}
|
||||
|
||||
func testRestorePolicySkipsWhenLaunchHasExplicitArguments() {
|
||||
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
|
||||
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "--window", "window:1"],
|
||||
environment: [:]
|
||||
)
|
||||
|
||||
XCTAssertFalse(shouldRestore)
|
||||
}
|
||||
|
||||
func testRestorePolicyAllowsFinderStyleLaunchArgumentsOnly() {
|
||||
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
|
||||
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "-psn_0_12345"],
|
||||
environment: [:]
|
||||
)
|
||||
|
||||
XCTAssertTrue(shouldRestore)
|
||||
}
|
||||
|
||||
func testRestorePolicySkipsWhenRunningUnderXCTest() {
|
||||
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
|
||||
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux"],
|
||||
environment: ["XCTestConfigurationFilePath": "/tmp/xctest.xctestconfiguration"]
|
||||
)
|
||||
|
||||
XCTAssertFalse(shouldRestore)
|
||||
}
|
||||
|
||||
func testSidebarWidthSanitizationClampsToPolicyRange() {
|
||||
XCTAssertEqual(
|
||||
SessionPersistencePolicy.sanitizedSidebarWidth(-20),
|
||||
SessionPersistencePolicy.minimumSidebarWidth,
|
||||
accuracy: 0.001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SessionPersistencePolicy.sanitizedSidebarWidth(10_000),
|
||||
SessionPersistencePolicy.maximumSidebarWidth,
|
||||
accuracy: 0.001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SessionPersistencePolicy.sanitizedSidebarWidth(nil),
|
||||
SessionPersistencePolicy.defaultSidebarWidth,
|
||||
accuracy: 0.001
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionRectSnapshotEncodesXYWidthHeightKeys() throws {
|
||||
let snapshot = SessionRectSnapshot(x: 101.25, y: 202.5, width: 903.75, height: 704.5)
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Double])
|
||||
|
||||
XCTAssertEqual(Set(object.keys), Set(["x", "y", "width", "height"]))
|
||||
XCTAssertEqual(try XCTUnwrap(object["x"]), 101.25, accuracy: 0.001)
|
||||
XCTAssertEqual(try XCTUnwrap(object["y"]), 202.5, accuracy: 0.001)
|
||||
XCTAssertEqual(try XCTUnwrap(object["width"]), 903.75, accuracy: 0.001)
|
||||
XCTAssertEqual(try XCTUnwrap(object["height"]), 704.5, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
|
||||
let source = SessionBrowserPanelSnapshot(
|
||||
urlString: "https://example.com/current",
|
||||
shouldRenderWebView: true,
|
||||
pageZoom: 1.2,
|
||||
developerToolsVisible: true,
|
||||
backHistoryURLStrings: [
|
||||
"https://example.com/a",
|
||||
"https://example.com/b"
|
||||
],
|
||||
forwardHistoryURLStrings: [
|
||||
"https://example.com/d"
|
||||
]
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(source)
|
||||
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
|
||||
XCTAssertEqual(decoded.urlString, source.urlString)
|
||||
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
|
||||
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
|
||||
}
|
||||
|
||||
func testSessionBrowserPanelSnapshotHistoryDecodesWhenKeysAreMissing() throws {
|
||||
let json = """
|
||||
{
|
||||
"urlString": "https://example.com/current",
|
||||
"shouldRenderWebView": true,
|
||||
"pageZoom": 1.0,
|
||||
"developerToolsVisible": false
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
|
||||
XCTAssertEqual(decoded.urlString, "https://example.com/current")
|
||||
XCTAssertNil(decoded.backHistoryURLStrings)
|
||||
XCTAssertNil(decoded.forwardHistoryURLStrings)
|
||||
}
|
||||
|
||||
func testScrollbackReplayEnvironmentWritesReplayFile() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let environment = SessionScrollbackReplayStore.replayEnvironment(
|
||||
for: "line one\nline two\n",
|
||||
tempDirectory: tempDir
|
||||
)
|
||||
|
||||
let path = environment[SessionScrollbackReplayStore.environmentKey]
|
||||
XCTAssertNotNil(path)
|
||||
XCTAssertTrue(path?.hasPrefix(tempDir.path) == true)
|
||||
|
||||
guard let path else { return }
|
||||
let contents = try? String(contentsOfFile: path, encoding: .utf8)
|
||||
XCTAssertEqual(contents, "line one\nline two\n")
|
||||
}
|
||||
|
||||
func testScrollbackReplayEnvironmentSkipsWhitespaceOnlyContent() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let environment = SessionScrollbackReplayStore.replayEnvironment(
|
||||
for: " \n\t ",
|
||||
tempDirectory: tempDir
|
||||
)
|
||||
|
||||
XCTAssertTrue(environment.isEmpty)
|
||||
}
|
||||
|
||||
func testScrollbackReplayEnvironmentPreservesANSIColorSequences() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let red = "\u{001B}[31m"
|
||||
let reset = "\u{001B}[0m"
|
||||
let source = "\(red)RED\(reset)\n"
|
||||
let environment = SessionScrollbackReplayStore.replayEnvironment(
|
||||
for: source,
|
||||
tempDirectory: tempDir
|
||||
)
|
||||
|
||||
guard let path = environment[SessionScrollbackReplayStore.environmentKey] else {
|
||||
XCTFail("Expected replay file path")
|
||||
return
|
||||
}
|
||||
|
||||
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
|
||||
XCTFail("Expected replay file contents")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(contents.contains("\(red)RED\(reset)"))
|
||||
XCTAssertTrue(contents.hasPrefix(reset))
|
||||
XCTAssertTrue(contents.hasSuffix(reset))
|
||||
}
|
||||
|
||||
func testTruncatedScrollbackAvoidsLeadingPartialANSICSISequence() {
|
||||
let maxChars = SessionPersistencePolicy.maxScrollbackCharactersPerTerminal
|
||||
let source = "\u{001B}[31m"
|
||||
+ String(repeating: "X", count: maxChars - 7)
|
||||
+ "\u{001B}[0m"
|
||||
|
||||
guard let truncated = SessionPersistencePolicy.truncatedScrollback(source) else {
|
||||
XCTFail("Expected truncated scrollback")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertFalse(truncated.hasPrefix("31m"))
|
||||
XCTAssertFalse(truncated.hasPrefix("[31m"))
|
||||
XCTAssertFalse(truncated.hasPrefix("m"))
|
||||
}
|
||||
|
||||
func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() {
|
||||
XCTAssertEqual(
|
||||
TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"),
|
||||
"/tmp/cmux-screen.txt"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "),
|
||||
"/tmp/cmux-screen.txt"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() {
|
||||
XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt"))
|
||||
XCTAssertNil(TerminalController.normalizedExportedScreenPath(" "))
|
||||
XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil))
|
||||
}
|
||||
|
||||
func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() {
|
||||
let tempRoot = URL(fileURLWithPath: "/tmp")
|
||||
.appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
let tempFile = tempRoot
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
.appendingPathComponent("screen.txt", isDirectory: false)
|
||||
let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt")
|
||||
|
||||
XCTAssertTrue(
|
||||
TerminalController.shouldRemoveExportedScreenDirectory(
|
||||
fileURL: tempFile,
|
||||
temporaryDirectory: tempRoot
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
TerminalController.shouldRemoveExportedScreenDirectory(
|
||||
fileURL: outsideFile,
|
||||
temporaryDirectory: tempRoot
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testShouldRemoveExportedScreenFileOnlyWithinTemporaryRoot() {
|
||||
let tempRoot = URL(fileURLWithPath: "/tmp")
|
||||
.appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
let tempFile = tempRoot
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
.appendingPathComponent("screen.txt", isDirectory: false)
|
||||
let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt")
|
||||
|
||||
XCTAssertTrue(
|
||||
TerminalController.shouldRemoveExportedScreenFile(
|
||||
fileURL: tempFile,
|
||||
temporaryDirectory: tempRoot
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
TerminalController.shouldRemoveExportedScreenFile(
|
||||
fileURL: outsideFile,
|
||||
temporaryDirectory: tempRoot
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testWindowUnregisterSnapshotPersistencePolicy() {
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: false)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: true)
|
||||
)
|
||||
}
|
||||
|
||||
func testShouldSkipSessionSaveDuringStartupRestorePolicy() {
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
|
||||
isApplyingStartupSessionRestore: true,
|
||||
includeScrollback: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
|
||||
isApplyingStartupSessionRestore: true,
|
||||
includeScrollback: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
|
||||
isApplyingStartupSessionRestore: false,
|
||||
includeScrollback: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testResolvedWindowFramePrefersSavedDisplayIdentity() {
|
||||
let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400)
|
||||
let savedDisplay = SessionDisplaySnapshot(
|
||||
displayID: 2,
|
||||
frame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
|
||||
// Display 1 and 2 swapped horizontal positions between snapshot and restore.
|
||||
let display1 = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
let display2 = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 2,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: savedDisplay,
|
||||
availableDisplays: [display1, display2],
|
||||
fallbackDisplay: display1
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertTrue(display2.visibleFrame.intersects(restored))
|
||||
XCTAssertFalse(display1.visibleFrame.intersects(restored))
|
||||
XCTAssertEqual(restored.width, 600, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 400, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minX, 200, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 100, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedWindowFrameKeepsIntersectingFrameWithoutDisplayMetadata() {
|
||||
let savedFrame = SessionRectSnapshot(x: 120, y: 80, width: 500, height: 350)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: nil,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertEqual(restored.minX, 120, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 80, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 500, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 350, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedStartupPrimaryWindowFrameFallsBackToPersistedGeometryWhenPrimaryMissing() {
|
||||
let fallbackFrame = SessionRectSnapshot(x: 180, y: 140, width: 900, height: 640)
|
||||
let fallbackDisplay = SessionDisplaySnapshot(
|
||||
displayID: 1,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedStartupPrimaryWindowFrame(
|
||||
primarySnapshot: nil,
|
||||
fallbackFrame: fallbackFrame,
|
||||
fallbackDisplaySnapshot: fallbackDisplay,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertEqual(restored.minX, 180, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 140, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 900, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 640, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedStartupPrimaryWindowFramePrefersPrimarySnapshotOverFallback() {
|
||||
let primarySnapshot = SessionWindowSnapshot(
|
||||
frame: SessionRectSnapshot(x: 220, y: 160, width: 980, height: 700),
|
||||
display: SessionDisplaySnapshot(
|
||||
displayID: 1,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
),
|
||||
tabManager: SessionTabManagerSnapshot(selectedWorkspaceIndex: nil, workspaces: []),
|
||||
sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 220)
|
||||
)
|
||||
let fallbackFrame = SessionRectSnapshot(x: 40, y: 30, width: 700, height: 500)
|
||||
let fallbackDisplay = SessionDisplaySnapshot(
|
||||
displayID: 1,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedStartupPrimaryWindowFrame(
|
||||
primarySnapshot: primarySnapshot,
|
||||
fallbackFrame: fallbackFrame,
|
||||
fallbackDisplaySnapshot: fallbackDisplay,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertEqual(restored.minX, 220, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 160, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 980, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 700, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() {
|
||||
let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: nil,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertTrue(display.visibleFrame.contains(restored))
|
||||
XCTAssertEqual(restored.minX, 50, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 50, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 900, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 700, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedWindowFramePreservesExactGeometryWhenDisplayIsUnchanged() {
|
||||
let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410)
|
||||
let savedDisplay = SessionDisplaySnapshot(
|
||||
displayID: 2,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410)
|
||||
)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 2,
|
||||
frame: CGRect(x: 0, y: 0, width: 2_560, height: 1_440),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 2_560, height: 1_410)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: savedDisplay,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertEqual(restored.minX, 1_303, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, -90, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 1_280, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 1_410, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedWindowFrameClampsWhenDisplayGeometryChangesEvenWithSameDisplayID() {
|
||||
let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410)
|
||||
let savedDisplay = SessionDisplaySnapshot(
|
||||
displayID: 2,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410)
|
||||
)
|
||||
let resizedDisplay = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 2,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_050)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: savedDisplay,
|
||||
availableDisplays: [resizedDisplay],
|
||||
fallbackDisplay: resizedDisplay
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertTrue(resizedDisplay.visibleFrame.contains(restored))
|
||||
XCTAssertNotEqual(restored.minX, 1_303, "Changed display geometry should clamp/remap frame")
|
||||
XCTAssertNotEqual(restored.minY, -90, "Changed display geometry should clamp/remap frame")
|
||||
}
|
||||
|
||||
func testResolvedSnapshotTerminalScrollbackPrefersCaptured() {
|
||||
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: "captured-value",
|
||||
fallbackScrollback: "fallback-value"
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved, "captured-value")
|
||||
}
|
||||
|
||||
func testResolvedSnapshotTerminalScrollbackFallsBackWhenCaptureMissing() {
|
||||
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: nil,
|
||||
fallbackScrollback: "fallback-value"
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved, "fallback-value")
|
||||
}
|
||||
|
||||
func testResolvedSnapshotTerminalScrollbackTruncatesFallback() {
|
||||
let oversizedFallback = String(
|
||||
repeating: "x",
|
||||
count: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + 37
|
||||
)
|
||||
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: nil,
|
||||
fallbackScrollback: oversizedFallback
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
resolved?.count,
|
||||
SessionPersistencePolicy.maxScrollbackCharactersPerTerminal
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSnapshot(version: Int) -> AppSessionSnapshot {
|
||||
let workspace = SessionWorkspaceSnapshot(
|
||||
processTitle: "Terminal",
|
||||
customTitle: "Restored",
|
||||
customColor: nil,
|
||||
isPinned: true,
|
||||
currentDirectory: "/tmp",
|
||||
focusedPanelId: nil,
|
||||
layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)),
|
||||
panels: [],
|
||||
statusEntries: [],
|
||||
logEntries: [],
|
||||
progress: nil,
|
||||
gitBranch: nil
|
||||
)
|
||||
|
||||
let tabManager = SessionTabManagerSnapshot(
|
||||
selectedWorkspaceIndex: 0,
|
||||
workspaces: [workspace]
|
||||
)
|
||||
|
||||
let window = SessionWindowSnapshot(
|
||||
frame: SessionRectSnapshot(x: 10, y: 20, width: 900, height: 700),
|
||||
display: SessionDisplaySnapshot(
|
||||
displayID: 42,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 1920, height: 1200),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 25, width: 1920, height: 1175)
|
||||
),
|
||||
tabManager: tabManager,
|
||||
sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240)
|
||||
)
|
||||
|
||||
return AppSessionSnapshot(
|
||||
version: version,
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
windows: [window]
|
||||
)
|
||||
}
|
||||
}
|
||||
49
cmuxTests/WorkspaceContentViewVisibilityTests.swift
Normal file
49
cmuxTests/WorkspaceContentViewVisibilityTests.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class WorkspaceContentViewVisibilityTests: XCTestCase {
|
||||
func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() {
|
||||
XCTAssertFalse(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: false,
|
||||
isSelectedInPane: true,
|
||||
isFocused: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsTrueForSelectedPanel() {
|
||||
XCTAssertTrue(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: true,
|
||||
isFocused: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() {
|
||||
XCTAssertTrue(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: false,
|
||||
isFocused: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() {
|
||||
XCTAssertFalse(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: false,
|
||||
isFocused: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import XCTest
|
||||
import AppKit
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
|
|
@ -106,3 +107,333 @@ final class WorkspaceManualUnreadTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteFuzzyMatcherTests: XCTestCase {
|
||||
func testExactMatchScoresHigherThanPrefixAndContains() {
|
||||
let exact = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab")
|
||||
let prefix = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab now")
|
||||
let contains = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "command rename tab flow")
|
||||
|
||||
XCTAssertNotNil(exact)
|
||||
XCTAssertNotNil(prefix)
|
||||
XCTAssertNotNil(contains)
|
||||
XCTAssertGreaterThan(exact ?? 0, prefix ?? 0)
|
||||
XCTAssertGreaterThan(prefix ?? 0, contains ?? 0)
|
||||
}
|
||||
|
||||
func testInitialismMatchReturnsScore() {
|
||||
let score = CommandPaletteFuzzyMatcher.score(query: "ocdi", candidate: "open current directory in ide")
|
||||
XCTAssertNotNil(score)
|
||||
XCTAssertGreaterThan(score ?? 0, 0)
|
||||
}
|
||||
|
||||
func testLongTokenLooseSubsequenceDoesNotMatch() {
|
||||
let score = CommandPaletteFuzzyMatcher.score(query: "rename", candidate: "open current directory in ide")
|
||||
XCTAssertNil(score)
|
||||
}
|
||||
|
||||
func testStitchedWordPrefixMatchesRetabForRenameTab() {
|
||||
let score = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…")
|
||||
XCTAssertNotNil(score)
|
||||
XCTAssertGreaterThan(score ?? 0, 0)
|
||||
}
|
||||
|
||||
func testRetabPrefersRenameTabOverDistantTabWord() {
|
||||
let renameTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…")
|
||||
let reopenTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Reopen Closed Browser Tab")
|
||||
|
||||
XCTAssertNotNil(renameTabScore)
|
||||
XCTAssertNotNil(reopenTabScore)
|
||||
XCTAssertGreaterThan(renameTabScore ?? 0, reopenTabScore ?? 0)
|
||||
}
|
||||
|
||||
func testRenameScoresHigherThanUnrelatedCommand() {
|
||||
let renameScore = CommandPaletteFuzzyMatcher.score(
|
||||
query: "rename",
|
||||
candidates: ["Rename Tab…", "Tab • Terminal 1", "rename", "tab", "title"]
|
||||
)
|
||||
let unrelatedScore = CommandPaletteFuzzyMatcher.score(
|
||||
query: "rename",
|
||||
candidates: [
|
||||
"Open Current Directory in IDE",
|
||||
"Terminal • Terminal 1",
|
||||
"terminal",
|
||||
"directory",
|
||||
"open",
|
||||
"ide",
|
||||
"code",
|
||||
"default app"
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertNotNil(renameScore)
|
||||
XCTAssertNotNil(unrelatedScore)
|
||||
XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore ?? 0)
|
||||
}
|
||||
|
||||
func testTokenMatchingRequiresAllTokens() {
|
||||
let match = CommandPaletteFuzzyMatcher.score(
|
||||
query: "rename workspace",
|
||||
candidates: ["Rename Workspace", "Workspace settings"]
|
||||
)
|
||||
let miss = CommandPaletteFuzzyMatcher.score(
|
||||
query: "rename workspace",
|
||||
candidates: ["Rename Tab", "Tab settings"]
|
||||
)
|
||||
|
||||
XCTAssertNotNil(match)
|
||||
XCTAssertNil(miss)
|
||||
}
|
||||
|
||||
func testEmptyQueryReturnsZeroScore() {
|
||||
let score = CommandPaletteFuzzyMatcher.score(query: " ", candidate: "anything")
|
||||
XCTAssertEqual(score, 0)
|
||||
}
|
||||
|
||||
func testMatchCharacterIndicesForContainsMatch() {
|
||||
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||||
query: "workspace",
|
||||
candidate: "New Workspace"
|
||||
)
|
||||
XCTAssertTrue(indices.contains(4))
|
||||
XCTAssertTrue(indices.contains(12))
|
||||
XCTAssertFalse(indices.contains(0))
|
||||
}
|
||||
|
||||
func testMatchCharacterIndicesForSubsequenceMatch() {
|
||||
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||||
query: "nws",
|
||||
candidate: "New Workspace"
|
||||
)
|
||||
XCTAssertTrue(indices.contains(0))
|
||||
XCTAssertTrue(indices.contains(2))
|
||||
XCTAssertTrue(indices.contains(8))
|
||||
}
|
||||
|
||||
func testMatchCharacterIndicesForStitchedWordPrefixMatch() {
|
||||
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||||
query: "retab",
|
||||
candidate: "Rename Tab…"
|
||||
)
|
||||
XCTAssertTrue(indices.contains(0))
|
||||
XCTAssertTrue(indices.contains(1))
|
||||
XCTAssertTrue(indices.contains(7))
|
||||
XCTAssertTrue(indices.contains(8))
|
||||
XCTAssertTrue(indices.contains(9))
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteSwitcherSearchIndexerTests: XCTestCase {
|
||||
func testKeywordsIncludeDirectoryBranchAndPortMetadata() {
|
||||
let metadata = CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"],
|
||||
branches: ["feature/cmd-palette-indexing"],
|
||||
ports: [3000, 9222]
|
||||
)
|
||||
|
||||
let keywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace", "switch"],
|
||||
metadata: metadata
|
||||
)
|
||||
|
||||
XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"))
|
||||
XCTAssertTrue(keywords.contains("feat-cmd-palette"))
|
||||
XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing"))
|
||||
XCTAssertTrue(keywords.contains("cmd-palette-indexing"))
|
||||
XCTAssertTrue(keywords.contains("3000"))
|
||||
XCTAssertTrue(keywords.contains(":9222"))
|
||||
}
|
||||
|
||||
func testFuzzyMatcherMatchesDirectoryBranchAndPortMetadata() {
|
||||
let metadata = CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/cmuxterm/worktrees/issue-123-switcher-search"],
|
||||
branches: ["fix/switcher-metadata"],
|
||||
ports: [4317]
|
||||
)
|
||||
|
||||
let candidates = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace"],
|
||||
metadata: metadata
|
||||
)
|
||||
|
||||
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-search", candidates: candidates))
|
||||
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-metadata", candidates: candidates))
|
||||
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "4317", candidates: candidates))
|
||||
}
|
||||
|
||||
func testWorkspaceDetailOmitsSplitDirectoryAndBranchTokens() {
|
||||
let metadata = CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"],
|
||||
branches: ["feature/cmd-palette-indexing"],
|
||||
ports: [3000]
|
||||
)
|
||||
|
||||
let keywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace"],
|
||||
metadata: metadata,
|
||||
detail: .workspace
|
||||
)
|
||||
|
||||
XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"))
|
||||
XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing"))
|
||||
XCTAssertTrue(keywords.contains("3000"))
|
||||
XCTAssertFalse(keywords.contains("feat-cmd-palette"))
|
||||
XCTAssertFalse(keywords.contains("cmd-palette-indexing"))
|
||||
}
|
||||
|
||||
func testSurfaceDetailOutranksWorkspaceDetailForPathToken() {
|
||||
let metadata = CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/worktrees/cmux"],
|
||||
branches: ["feature/cmd-palette"],
|
||||
ports: []
|
||||
)
|
||||
|
||||
let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace"],
|
||||
metadata: metadata,
|
||||
detail: .workspace
|
||||
)
|
||||
let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["surface"],
|
||||
metadata: metadata,
|
||||
detail: .surface
|
||||
)
|
||||
|
||||
let workspaceScore = try XCTUnwrap(
|
||||
CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: workspaceKeywords)
|
||||
)
|
||||
let surfaceScore = try XCTUnwrap(
|
||||
CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: surfaceKeywords)
|
||||
)
|
||||
|
||||
XCTAssertGreaterThan(
|
||||
surfaceScore,
|
||||
workspaceScore,
|
||||
"Surface rows should rank ahead of workspace rows for directory-token matches."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CommandPaletteRequestRoutingTests: XCTestCase {
|
||||
private func makeWindow() -> NSWindow {
|
||||
NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
}
|
||||
|
||||
func testRequestedWindowTargetsOnlyMatchingObservedWindow() {
|
||||
let windowA = makeWindow()
|
||||
let windowB = makeWindow()
|
||||
|
||||
XCTAssertTrue(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: windowA,
|
||||
requestedWindow: windowA,
|
||||
keyWindow: windowA,
|
||||
mainWindow: windowA
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: windowB,
|
||||
requestedWindow: windowA,
|
||||
keyWindow: windowA,
|
||||
mainWindow: windowA
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNilRequestedWindowFallsBackToKeyWindow() {
|
||||
let key = makeWindow()
|
||||
let other = makeWindow()
|
||||
|
||||
XCTAssertTrue(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: key,
|
||||
requestedWindow: nil,
|
||||
keyWindow: key,
|
||||
mainWindow: nil
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: other,
|
||||
requestedWindow: nil,
|
||||
keyWindow: key,
|
||||
mainWindow: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNilRequestedAndKeyFallsBackToMainWindow() {
|
||||
let main = makeWindow()
|
||||
let other = makeWindow()
|
||||
|
||||
XCTAssertTrue(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: main,
|
||||
requestedWindow: nil,
|
||||
keyWindow: nil,
|
||||
mainWindow: main
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: other,
|
||||
requestedWindow: nil,
|
||||
keyWindow: nil,
|
||||
mainWindow: main
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNoObservedWindowNeverHandlesRequest() {
|
||||
XCTAssertFalse(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: nil,
|
||||
requestedWindow: makeWindow(),
|
||||
keyWindow: makeWindow(),
|
||||
mainWindow: makeWindow()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteBackNavigationTests: XCTestCase {
|
||||
func testBackspaceOnEmptyRenameInputReturnsToCommandList() {
|
||||
XCTAssertTrue(
|
||||
ContentView.commandPaletteShouldPopRenameInputOnDelete(
|
||||
renameDraft: "",
|
||||
modifiers: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testBackspaceWithRenameTextDoesNotReturnToCommandList() {
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldPopRenameInputOnDelete(
|
||||
renameDraft: "Terminal 1",
|
||||
modifiers: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testModifiedBackspaceDoesNotReturnToCommandList() {
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldPopRenameInputOnDelete(
|
||||
renameDraft: "",
|
||||
modifiers: [.control]
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldPopRenameInputOnDelete(
|
||||
renameDraft: "",
|
||||
modifiers: [.command]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -546,6 +546,68 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
func testCtrlDEarlyDuringSplitStartupKeepsWindowOpen() {
|
||||
let attempts = 12
|
||||
for attempt in 1...attempts {
|
||||
let app = XCUIApplication()
|
||||
let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-ctrl-\(UUID().uuidString).json"
|
||||
try? FileManager.default.removeItem(atPath: dataPath)
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] = "lr"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_d"
|
||||
app.launch()
|
||||
app.activate()
|
||||
defer { app.terminate() }
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForAnyJSON(atPath: dataPath, timeout: 12.0),
|
||||
"Attempt \(attempt): expected early Ctrl+D setup data at \(dataPath)"
|
||||
)
|
||||
guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else {
|
||||
XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])")
|
||||
return
|
||||
}
|
||||
|
||||
if let setupError = done["setupError"], !setupError.isEmpty {
|
||||
XCTFail("Attempt \(attempt): setup failed: \(setupError)")
|
||||
return
|
||||
}
|
||||
|
||||
let workspaceCountAfter = Int(done["workspaceCountAfter"] ?? "") ?? -1
|
||||
let panelCountAfter = Int(done["panelCountAfter"] ?? "") ?? -1
|
||||
let closedWorkspace = (done["closedWorkspace"] ?? "") == "1"
|
||||
let timedOut = (done["timedOut"] ?? "") == "1"
|
||||
let triggerMode = done["autoTriggerMode"] ?? ""
|
||||
let exitPanelId = done["exitPanelId"] ?? ""
|
||||
let workspaceId = done["workspaceId"] ?? ""
|
||||
let probeSurfaceId = done["probeShowChildExitedSurfaceId"] ?? ""
|
||||
let probeTabId = done["probeShowChildExitedTabId"] ?? ""
|
||||
|
||||
XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+D timed out. data=\(done)")
|
||||
XCTAssertEqual(triggerMode, "strict_early_ctrl_d", "Attempt \(attempt): expected strict early Ctrl+D trigger mode. data=\(done)")
|
||||
XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+D. data=\(done)")
|
||||
XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+D. data=\(done)")
|
||||
XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+D. data=\(done)")
|
||||
if let showChildExitedCount = Int(done["probeShowChildExitedCount"] ?? "") {
|
||||
XCTAssertEqual(showChildExitedCount, 1, "Attempt \(attempt): expected exactly one SHOW_CHILD_EXITED callback for one early Ctrl+D. data=\(done)")
|
||||
}
|
||||
if !exitPanelId.isEmpty, !probeSurfaceId.isEmpty {
|
||||
XCTAssertEqual(probeSurfaceId, exitPanelId, "Attempt \(attempt): SHOW_CHILD_EXITED should target the split opened by Cmd+D. data=\(done)")
|
||||
}
|
||||
if !workspaceId.isEmpty, !probeTabId.isEmpty {
|
||||
XCTAssertEqual(probeTabId, workspaceId, "Attempt \(attempt): SHOW_CHILD_EXITED should resolve to the active workspace. data=\(done)")
|
||||
}
|
||||
XCTAssertTrue(
|
||||
waitForWindowCount(app: app, atLeast: 1, timeout: 2.0),
|
||||
"Attempt \(attempt): app window should remain open after early Ctrl+D. data=\(done)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
|
|
|
|||
|
|
@ -35,4 +35,31 @@ final class SidebarResizeUITests: XCTestCase {
|
|||
XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left")
|
||||
XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset")
|
||||
}
|
||||
|
||||
func testSidebarResizerHasMaximumWidthCap() {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.waitForExistence(timeout: 5.0))
|
||||
|
||||
let elements = app.descendants(matching: .any)
|
||||
let resizer = elements["SidebarResizer"]
|
||||
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
|
||||
|
||||
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||
let farRight = start.withOffset(CGVector(dx: 5000, dy: 0))
|
||||
start.press(forDuration: 0.1, thenDragTo: farRight)
|
||||
|
||||
let windowFrame = window.frame
|
||||
let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX)
|
||||
let minimumExpectedRemaining = windowFrame.width * 0.45
|
||||
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
remainingWidth,
|
||||
minimumExpectedRemaining,
|
||||
"Expected sidebar max-width clamp to leave substantial terminal width. " +
|
||||
"remaining=\(remainingWidth), window=\(windowFrame.width)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
|
|||
|
||||
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1"
|
||||
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
|
||||
RUN_TAG="tests-v1"
|
||||
|
||||
echo "== build =="
|
||||
# Work around stale explicit-module cache artifacts (notably Sentry headers) that can
|
||||
|
|
@ -51,7 +52,7 @@ launch_and_wait() {
|
|||
defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true
|
||||
|
||||
# Launch directly with UI test mode enabled so startup follows deterministic test codepaths.
|
||||
CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
|
||||
SOCK=""
|
||||
for _ in {1..120}; do
|
||||
|
|
@ -70,7 +71,7 @@ launch_and_wait() {
|
|||
export CMUX_SOCKET="$SOCK"
|
||||
|
||||
# Ensure LaunchServices has a visible/main window attached for rendering checks.
|
||||
open "$APP" >/dev/null 2>&1 || true
|
||||
CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
|
||||
echo "== wait ready =="
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
|
|||
|
||||
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2"
|
||||
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
|
||||
RUN_TAG="tests-v2"
|
||||
|
||||
echo "== build =="
|
||||
# Work around stale explicit-module cache artifacts (notably Sentry headers) that can
|
||||
|
|
@ -51,7 +52,7 @@ launch_and_wait() {
|
|||
defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true
|
||||
|
||||
# Launch directly with UI test mode enabled so startup follows deterministic test codepaths.
|
||||
CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
|
||||
SOCK=""
|
||||
for _ in {1..120}; do
|
||||
|
|
@ -70,7 +71,7 @@ launch_and_wait() {
|
|||
export CMUX_SOCKET="$SOCK"
|
||||
|
||||
# Ensure LaunchServices has a visible/main window attached for rendering checks.
|
||||
open "$APP" >/dev/null 2>&1 || true
|
||||
CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
|
||||
echo "== wait ready =="
|
||||
|
|
|
|||
126
tests/test_browser_chrome_contrast_regression.py
Normal file
126
tests/test_browser_chrome_contrast_regression.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guards for browser chrome contrast in mixed theme setups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
source = view_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
try:
|
||||
browser_panel_view_block = extract_block(source, "struct BrowserPanelView: View")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
browser_panel_view_block = ""
|
||||
|
||||
try:
|
||||
resolver_block = extract_block(source, "func resolvedBrowserChromeColorScheme(")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
resolver_block = ""
|
||||
|
||||
if resolver_block:
|
||||
if "backgroundColor.isLightColor ? .light : .dark" not in resolver_block:
|
||||
failures.append(
|
||||
"resolvedBrowserChromeColorScheme must map luminance to a light/dark ColorScheme"
|
||||
)
|
||||
|
||||
try:
|
||||
chrome_scheme_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var browserChromeColorScheme: ColorScheme",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
chrome_scheme_block = ""
|
||||
|
||||
if chrome_scheme_block and "resolvedBrowserChromeColorScheme(" not in chrome_scheme_block:
|
||||
failures.append("browserChromeColorScheme must use resolvedBrowserChromeColorScheme")
|
||||
|
||||
try:
|
||||
omnibar_background_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var omnibarPillBackgroundColor: NSColor",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
omnibar_background_block = ""
|
||||
|
||||
if omnibar_background_block and "for: browserChromeColorScheme" not in omnibar_background_block:
|
||||
failures.append("omnibar pill background must use browserChromeColorScheme")
|
||||
|
||||
try:
|
||||
address_bar_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var addressBar: some View",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
address_bar_block = ""
|
||||
|
||||
if address_bar_block and ".environment(\\.colorScheme, browserChromeColorScheme)" not in address_bar_block:
|
||||
failures.append("addressBar must apply browserChromeColorScheme via environment")
|
||||
|
||||
try:
|
||||
body_block = extract_block(browser_panel_view_block, "var body: some View")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
body_block = ""
|
||||
|
||||
if body_block:
|
||||
if "OmnibarSuggestionsView(" not in body_block:
|
||||
failures.append("Expected OmnibarSuggestionsView block in BrowserPanelView body")
|
||||
elif ".environment(\\.colorScheme, browserChromeColorScheme)" not in body_block:
|
||||
failures.append("Omnibar suggestions must apply browserChromeColorScheme via environment")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser chrome contrast regression guards failed")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser chrome contrast regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
86
tests/test_browser_console_errors_cli_output_regression.py
Normal file
86
tests/test_browser_console_errors_cli_output_regression.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guard for browser console/errors CLI output formatting.
|
||||
|
||||
Ensures non-JSON `browser console list` and `browser errors list` do not fall
|
||||
back to unconditional `OK` when logs exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
cli_path = root / "CLI" / "cmux.swift"
|
||||
cli_source = cli_path.read_text(encoding="utf-8")
|
||||
browser_block = extract_block(cli_source, "private func runBrowserCommand(")
|
||||
|
||||
if "func displayBrowserLogItems(_ value: Any?) -> String?" not in browser_block:
|
||||
failures.append("runBrowserCommand() is missing displayBrowserLogItems() helper")
|
||||
else:
|
||||
helper_block = extract_block(browser_block, "func displayBrowserLogItems(_ value: Any?) -> String?")
|
||||
if "return \"[\\(level)] \\(text)\"" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer renders level-prefixed log lines")
|
||||
if "return \"[error] \\(message)\"" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer renders concise JS error messages")
|
||||
if "return displayBrowserValue(dict)" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer falls back to structured formatting")
|
||||
|
||||
console_block = extract_block(browser_block, 'if subcommand == "console"')
|
||||
if 'displayBrowserLogItems(payload["entries"])' not in console_block:
|
||||
failures.append("browser console path no longer formats entries for non-JSON output")
|
||||
if 'output(payload, fallback: "OK")' in console_block:
|
||||
failures.append("browser console path regressed to unconditional OK output")
|
||||
|
||||
errors_block = extract_block(browser_block, 'if subcommand == "errors"')
|
||||
if 'displayBrowserLogItems(payload["errors"])' not in errors_block:
|
||||
failures.append("browser errors path no longer formats errors for non-JSON output")
|
||||
if 'output(payload, fallback: "OK")' in errors_block:
|
||||
failures.append("browser errors path regressed to unconditional OK output")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser console/errors CLI output regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser console/errors CLI output regression guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
87
tests/test_browser_eval_cli_output_regression.py
Normal file
87
tests/test_browser_eval_cli_output_regression.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guard for browser eval CLI output formatting.
|
||||
|
||||
Ensures `cmux browser <surface> eval <script>` prints the evaluated value
|
||||
instead of always printing `OK`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
cli_path = root / "CLI" / "cmux.swift"
|
||||
cli_source = cli_path.read_text(encoding="utf-8")
|
||||
browser_block = extract_block(cli_source, "private func runBrowserCommand(")
|
||||
|
||||
if "func displayBrowserValue(_ value: Any) -> String" not in browser_block:
|
||||
failures.append("runBrowserCommand() is missing displayBrowserValue() helper")
|
||||
else:
|
||||
value_block = extract_block(browser_block, "func displayBrowserValue(_ value: Any) -> String")
|
||||
required_guards = [
|
||||
"if value is NSNull",
|
||||
"if let string = value as? String",
|
||||
"if let bool = value as? Bool",
|
||||
"if let number = value as? NSNumber",
|
||||
]
|
||||
for guard in required_guards:
|
||||
if guard not in value_block:
|
||||
failures.append(f"displayBrowserValue() no longer handles: {guard}")
|
||||
|
||||
eval_block = extract_block(browser_block, 'if subcommand == "eval"')
|
||||
if 'let payload = try client.sendV2(method: "browser.eval"' not in eval_block:
|
||||
failures.append("browser eval path no longer calls browser.eval v2 method")
|
||||
if 'if let value = payload["value"]' not in eval_block:
|
||||
failures.append("browser eval path no longer reads payload value")
|
||||
if "fallback = displayBrowserValue(value)" not in eval_block:
|
||||
failures.append("browser eval path no longer formats payload value for CLI output")
|
||||
if 'output(payload, fallback: "OK")' in eval_block:
|
||||
failures.append("browser eval path regressed to unconditional OK output")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser eval CLI output regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser eval CLI output regression guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
388
tests/test_browser_new_tab_surface_focus_omnibar.py
Normal file
388
tests/test_browser_new_tab_surface_focus_omnibar.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test:
|
||||
1. Focusing a blank browser surface should focus the omnibar.
|
||||
2. Focusing a pane that contains a blank browser should focus the omnibar.
|
||||
3. If command palette is open, focusing that blank browser surface must not steal input.
|
||||
4. Cmd+P switcher focusing an existing blank browser surface should focus the omnibar.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
def v2_call(client: cmux, method: str, params: dict[str, Any] | None = None, request_id: str = "1") -> dict[str, Any]:
|
||||
payload = {
|
||||
"id": request_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
raw = client._send_command(json.dumps(payload))
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise cmuxError(f"Invalid v2 JSON response for {method}: {raw}") from exc
|
||||
|
||||
if not parsed.get("ok"):
|
||||
raise cmuxError(f"v2 {method} failed: {parsed.get('error')}")
|
||||
|
||||
result = parsed.get("result")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def wait_for(predicate, timeout_s: float, interval_s: float = 0.1) -> bool:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval_s)
|
||||
return False
|
||||
|
||||
|
||||
def browser_address_bar_focus_state(client: cmux, surface_id: str | None = None, request_id: str = "browser-focus") -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if surface_id:
|
||||
params["surface_id"] = surface_id
|
||||
return v2_call(client, "debug.browser.address_bar_focused", params, request_id=request_id)
|
||||
|
||||
|
||||
def set_command_palette_visible(client: cmux, window_id: str, target_visible: bool) -> bool:
|
||||
for idx in range(5):
|
||||
state = v2_call(
|
||||
client,
|
||||
"debug.command_palette.visible",
|
||||
{"window_id": window_id},
|
||||
request_id=f"palette-visible-{idx}",
|
||||
)
|
||||
is_visible = bool(state.get("visible"))
|
||||
if is_visible == target_visible:
|
||||
return True
|
||||
v2_call(
|
||||
client,
|
||||
"debug.command_palette.toggle",
|
||||
{"window_id": window_id},
|
||||
request_id=f"palette-toggle-{idx}",
|
||||
)
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
|
||||
def command_palette_results(client: cmux, window_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
payload = v2_call(
|
||||
client,
|
||||
"debug.command_palette.results",
|
||||
{"window_id": window_id, "limit": limit},
|
||||
request_id="palette-results"
|
||||
)
|
||||
rows = payload.get("results")
|
||||
if isinstance(rows, list):
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def command_palette_selected_index(client: cmux, window_id: str) -> int:
|
||||
payload = v2_call(
|
||||
client,
|
||||
"debug.command_palette.selection",
|
||||
{"window_id": window_id},
|
||||
request_id="palette-selection"
|
||||
)
|
||||
selected_index = payload.get("selected_index")
|
||||
if isinstance(selected_index, int):
|
||||
return max(0, selected_index)
|
||||
return 0
|
||||
|
||||
|
||||
def move_command_palette_selection_to_index(client: cmux, window_id: str, target_index: int) -> bool:
|
||||
target = max(0, target_index)
|
||||
for _ in range(40):
|
||||
current = command_palette_selected_index(client, window_id)
|
||||
if current == target:
|
||||
return True
|
||||
if current < target:
|
||||
client.simulate_shortcut("down")
|
||||
else:
|
||||
client.simulate_shortcut("up")
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
def current_window_id(client: cmux) -> str:
|
||||
window_current = v2_call(client, "window.current", request_id="window-current")
|
||||
window_id = window_current.get("window_id")
|
||||
if not isinstance(window_id, str) or not window_id:
|
||||
raise cmuxError(f"Invalid window.current payload: {window_current}")
|
||||
return window_id
|
||||
|
||||
|
||||
def main() -> int:
|
||||
client = cmux()
|
||||
workspace_ids: list[str] = []
|
||||
window_id: str | None = None
|
||||
|
||||
try:
|
||||
client.connect()
|
||||
client.activate_app()
|
||||
|
||||
# Scenario 1: focus_surface on a blank browser should focus omnibar.
|
||||
workspace_id = client.new_workspace()
|
||||
workspace_ids.append(workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.4)
|
||||
window_id = current_window_id(client)
|
||||
if not set_command_palette_visible(client, window_id, False):
|
||||
raise cmuxError("Failed to ensure command palette is hidden for scenario 1")
|
||||
|
||||
browser_id = client.new_surface(panel_type="browser")
|
||||
time.sleep(0.3)
|
||||
|
||||
surfaces = client.list_surfaces()
|
||||
terminal_id = next((surface_id for _, surface_id, _ in surfaces if surface_id != browser_id), None)
|
||||
if not terminal_id:
|
||||
raise cmuxError("Missing terminal surface for focus setup")
|
||||
|
||||
client.focus_surface_by_panel(terminal_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
# Primary behavior: focusing a blank browser tab should focus the omnibar.
|
||||
client.focus_surface_by_panel(browser_id)
|
||||
did_focus_address_bar = wait_for(
|
||||
lambda: bool(
|
||||
browser_address_bar_focus_state(
|
||||
client,
|
||||
surface_id=browser_id,
|
||||
request_id="browser-focus-primary"
|
||||
).get("focused")
|
||||
),
|
||||
timeout_s=3.0,
|
||||
interval_s=0.1
|
||||
)
|
||||
if not did_focus_address_bar:
|
||||
raise cmuxError("Blank browser surface did not focus omnibar after focus_surface")
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_ids.remove(workspace_id)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Scenario 2: focusing a pane that contains a blank browser should focus omnibar.
|
||||
workspace_id = client.new_workspace()
|
||||
workspace_ids.append(workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.4)
|
||||
window_id = current_window_id(client)
|
||||
if not set_command_palette_visible(client, window_id, False):
|
||||
raise cmuxError("Failed to ensure command palette is hidden for scenario 2")
|
||||
|
||||
initial_surfaces = client.list_surfaces()
|
||||
left_terminal_id = next((surface_id for _, surface_id, _ in initial_surfaces), None)
|
||||
if not left_terminal_id:
|
||||
raise cmuxError("Missing initial terminal surface for split setup")
|
||||
|
||||
split_browser_id = client.new_pane(direction="right", panel_type="browser")
|
||||
time.sleep(0.3)
|
||||
|
||||
pane_rows = client.list_panes()
|
||||
left_pane: str | None = None
|
||||
browser_pane: str | None = None
|
||||
for _, pane_id, _, _ in pane_rows:
|
||||
pane_surface_ids = {surface_id for _, surface_id, _, _ in client.list_pane_surfaces(pane_id)}
|
||||
if left_terminal_id in pane_surface_ids:
|
||||
left_pane = pane_id
|
||||
if split_browser_id in pane_surface_ids:
|
||||
browser_pane = pane_id
|
||||
|
||||
if not left_pane or not browser_pane:
|
||||
raise cmuxError("Failed to locate split panes for pane-focus scenario")
|
||||
|
||||
client.focus_pane(left_pane)
|
||||
time.sleep(0.2)
|
||||
client.focus_pane(browser_pane)
|
||||
|
||||
did_focus_split_browser = wait_for(
|
||||
lambda: bool(
|
||||
browser_address_bar_focus_state(
|
||||
client,
|
||||
surface_id=split_browser_id,
|
||||
request_id="browser-focus-pane"
|
||||
).get("focused")
|
||||
),
|
||||
timeout_s=3.0,
|
||||
interval_s=0.1
|
||||
)
|
||||
if not did_focus_split_browser:
|
||||
raise cmuxError("Blank browser pane did not focus omnibar after focus_pane")
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_ids.remove(workspace_id)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Scenario 3: command palette should keep input focus when switching to a blank browser surface.
|
||||
workspace_id = client.new_workspace()
|
||||
workspace_ids.append(workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.4)
|
||||
window_id = current_window_id(client)
|
||||
if not set_command_palette_visible(client, window_id, False):
|
||||
raise cmuxError("Failed to reset command palette before scenario 3")
|
||||
|
||||
blank_browser_id = client.new_surface(panel_type="browser")
|
||||
time.sleep(0.3)
|
||||
|
||||
surfaces = client.list_surfaces()
|
||||
terminal_id = next((surface_id for _, surface_id, _ in surfaces if surface_id != blank_browser_id), None)
|
||||
if not terminal_id:
|
||||
raise cmuxError("Missing terminal surface for command palette scenario")
|
||||
|
||||
client.focus_surface_by_panel(terminal_id)
|
||||
wait_for(
|
||||
lambda: not bool(
|
||||
browser_address_bar_focus_state(
|
||||
client,
|
||||
request_id="browser-focus-cleared"
|
||||
).get("focused")
|
||||
),
|
||||
timeout_s=2.0,
|
||||
interval_s=0.1
|
||||
)
|
||||
|
||||
if not set_command_palette_visible(client, window_id, True):
|
||||
raise cmuxError("Failed to open command palette")
|
||||
|
||||
client.focus_surface_by_panel(blank_browser_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
palette_visible_after_focus = bool(
|
||||
v2_call(
|
||||
client,
|
||||
"debug.command_palette.visible",
|
||||
{"window_id": window_id},
|
||||
request_id="palette-visible-after-focus"
|
||||
).get("visible")
|
||||
)
|
||||
if not palette_visible_after_focus:
|
||||
raise cmuxError("Command palette closed unexpectedly after focus_surface")
|
||||
|
||||
blank_focus_state = browser_address_bar_focus_state(
|
||||
client,
|
||||
surface_id=blank_browser_id,
|
||||
request_id="browser-focus-palette"
|
||||
)
|
||||
if bool(blank_focus_state.get("focused")):
|
||||
raise cmuxError("Blank browser tab stole omnibar focus while command palette was visible")
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_ids.remove(workspace_id)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Scenario 4: Cmd+P switcher selecting an existing blank browser surface should focus omnibar.
|
||||
workspace_id = client.new_workspace()
|
||||
workspace_ids.append(workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.4)
|
||||
window_id = current_window_id(client)
|
||||
if not set_command_palette_visible(client, window_id, False):
|
||||
raise cmuxError("Failed to reset command palette before scenario 4")
|
||||
|
||||
switcher_browser_id = client.new_surface(panel_type="browser")
|
||||
time.sleep(0.3)
|
||||
|
||||
switcher_surfaces = client.list_surfaces()
|
||||
switcher_terminal_id = next((surface_id for _, surface_id, _ in switcher_surfaces if surface_id != switcher_browser_id), None)
|
||||
if not switcher_terminal_id:
|
||||
raise cmuxError("Missing terminal surface for Cmd+P switcher scenario")
|
||||
|
||||
client.focus_surface_by_panel(switcher_terminal_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.simulate_shortcut("cmd+p")
|
||||
if not wait_for(
|
||||
lambda: bool(
|
||||
v2_call(
|
||||
client,
|
||||
"debug.command_palette.visible",
|
||||
{"window_id": window_id},
|
||||
request_id="palette-visible-switcher-open"
|
||||
).get("visible")
|
||||
),
|
||||
timeout_s=2.0,
|
||||
interval_s=0.1
|
||||
):
|
||||
raise cmuxError("Cmd+P did not open command palette switcher")
|
||||
|
||||
client.simulate_type("new tab")
|
||||
time.sleep(0.2)
|
||||
|
||||
target_command_id = f"switcher.surface.{workspace_id.lower()}.{switcher_browser_id.lower()}"
|
||||
switcher_results = command_palette_results(client, window_id, limit=50)
|
||||
target_index = next(
|
||||
(
|
||||
idx for idx, row in enumerate(switcher_results)
|
||||
if isinstance(row.get("command_id"), str) and row.get("command_id") == target_command_id
|
||||
),
|
||||
None
|
||||
)
|
||||
if target_index is None:
|
||||
raise cmuxError(f"Cmd+P switcher did not list target surface command {target_command_id}")
|
||||
|
||||
if not move_command_palette_selection_to_index(client, window_id, target_index):
|
||||
raise cmuxError(f"Failed to move Cmd+P selection to result index {target_index}")
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
|
||||
did_focus_switcher_target = wait_for(
|
||||
lambda: (
|
||||
not bool(
|
||||
v2_call(
|
||||
client,
|
||||
"debug.command_palette.visible",
|
||||
{"window_id": window_id},
|
||||
request_id="palette-visible-switcher-after-enter"
|
||||
).get("visible")
|
||||
)
|
||||
and bool(
|
||||
browser_address_bar_focus_state(
|
||||
client,
|
||||
surface_id=switcher_browser_id,
|
||||
request_id="browser-focus-switcher"
|
||||
).get("focused")
|
||||
)
|
||||
),
|
||||
timeout_s=3.0,
|
||||
interval_s=0.1
|
||||
)
|
||||
if not did_focus_switcher_target:
|
||||
raise cmuxError("Cmd+P switcher focus to blank browser did not focus omnibar")
|
||||
|
||||
print("PASS: blank-browser focus paths (surface, pane, and Cmd+P switcher) drive omnibar, while command palette visibility blocks focus stealing")
|
||||
return 0
|
||||
|
||||
except cmuxError as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
finally:
|
||||
if window_id:
|
||||
try:
|
||||
_ = set_command_palette_visible(client, window_id, False)
|
||||
except Exception:
|
||||
pass
|
||||
for workspace_id in list(workspace_ids):
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
29
tests/test_ci_self_hosted_guard.sh
Executable file
29
tests/test_ci_self_hosted_guard.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test for https://github.com/manaflow-ai/cmux/issues/385.
|
||||
# Ensures self-hosted UI tests are never run for fork pull requests.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml"
|
||||
|
||||
EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository"
|
||||
|
||||
if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then
|
||||
echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE"
|
||||
echo "Expected line:"
|
||||
echo " $EXPECTED_IF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ ui-tests:/ { in_ui_tests=1; next }
|
||||
in_ui_tests && /^ [^[:space:]]/ { in_ui_tests=0 }
|
||||
in_ui_tests && /runs-on: self-hosted/ { saw_self_hosted=1 }
|
||||
in_ui_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 }
|
||||
END { exit !(saw_self_hosted && saw_guard) }
|
||||
' "$WORKFLOW_FILE"; then
|
||||
echo "FAIL: ui-tests block must keep both self-hosted and fork guard"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: ui-tests self-hosted fork guard is present"
|
||||
126
tests/test_command_palette_update_commands.py
Executable file
126
tests/test_command_palette_update_commands.py
Executable file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for command-palette update command wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def expect_regex(content: str, pattern: str, message: str, failures: list[str]) -> None:
|
||||
if re.search(pattern, content, flags=re.DOTALL) is None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
content_view_path = repo_root / "Sources" / "ContentView.swift"
|
||||
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
|
||||
controller_path = repo_root / "Sources" / "Update" / "UpdateController.swift"
|
||||
|
||||
missing_paths = [
|
||||
str(path)
|
||||
for path in [content_view_path, app_delegate_path, controller_path]
|
||||
if not path.exists()
|
||||
]
|
||||
if missing_paths:
|
||||
print("Missing expected files:")
|
||||
for path in missing_paths:
|
||||
print(f" - {path}")
|
||||
return 1
|
||||
|
||||
content_view = read_text(content_view_path)
|
||||
app_delegate = read_text(app_delegate_path)
|
||||
controller = read_text(controller_path)
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'static\s+let\s+updateHasAvailable\s*=\s*"update\.hasAvailable"',
|
||||
"Missing `CommandPaletteContextKeys.updateHasAvailable`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'if\s+case\s+\.updateAvailable\s*=\s*updateViewModel\.effectiveState\s*\{\s*snapshot\.setBool\(CommandPaletteContextKeys\.updateHasAvailable,\s*true\)\s*\}',
|
||||
"Command palette context no longer tracks update-available state",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\].*?when:\s*\{\s*\$0\.bool\(CommandPaletteContextKeys\.updateHasAvailable\)\s*\}',
|
||||
"Missing or incomplete `palette.applyUpdateIfAvailable` contribution visibility gating",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'commandId:\s*"palette\.attemptUpdate".*?title:\s*constant\("Attempt Update"\).*?keywords:\s*\[[^\]]*"attempt"[^\]]*"check"[^\]]*"update"[^\]]*\]',
|
||||
"Missing or incomplete `palette.attemptUpdate` contribution",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'registry\.register\(commandId:\s*"palette\.applyUpdateIfAvailable"\)\s*\{\s*AppDelegate\.shared\?\.applyUpdateIfAvailable\(nil\)\s*\}',
|
||||
"Missing handler registration for `palette.applyUpdateIfAvailable`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'registry\.register\(commandId:\s*"palette\.attemptUpdate"\)\s*\{\s*AppDelegate\.shared\?\.attemptUpdate\(nil\)\s*\}',
|
||||
"Missing handler registration for `palette.attemptUpdate`",
|
||||
failures,
|
||||
)
|
||||
|
||||
expect_regex(
|
||||
app_delegate,
|
||||
r'@objc\s+func\s+applyUpdateIfAvailable\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.installUpdate\(\)\s*\}',
|
||||
"`AppDelegate.applyUpdateIfAvailable` is missing or does not call `updateController.installUpdate()`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
app_delegate,
|
||||
r'@objc\s+func\s+attemptUpdate\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.attemptUpdate\(\)\s*\}',
|
||||
"`AppDelegate.attemptUpdate` is missing or does not call `updateController.attemptUpdate()`",
|
||||
failures,
|
||||
)
|
||||
|
||||
expect_regex(
|
||||
controller,
|
||||
r'func\s+attemptUpdate\(\)\s*\{',
|
||||
"`UpdateController.attemptUpdate()` is missing",
|
||||
failures,
|
||||
)
|
||||
if "state.confirm()" not in controller:
|
||||
failures.append("`UpdateController.attemptUpdate()` no longer auto-confirms update installation")
|
||||
if "checkForUpdates()" not in controller:
|
||||
failures.append("`UpdateController.attemptUpdate()` no longer triggers a check before install")
|
||||
|
||||
if failures:
|
||||
print("FAIL: command-palette update command regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: command-palette update commands expose apply + attempt wiring")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
64
tests/test_focus_panel_reentrant_guard_regression.py
Normal file
64
tests/test_focus_panel_reentrant_guard_regression.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for re-entrant terminal focus guard.
|
||||
|
||||
Guards the fix for split-drag focus churn where:
|
||||
becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects
|
||||
could repeatedly re-enter and spike CPU.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
workspace_path = root / "Sources" / "Workspace.swift"
|
||||
workspace_source = workspace_path.read_text(encoding="utf-8")
|
||||
|
||||
required_workspace_snippets = [
|
||||
"enum FocusPanelTrigger {",
|
||||
"case terminalFirstResponder",
|
||||
"trigger: FocusPanelTrigger = .standard",
|
||||
"let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged",
|
||||
"if let targetPaneId, !shouldSuppressReentrantRefocus {",
|
||||
"reason=firstResponderAlreadyConverged",
|
||||
]
|
||||
for snippet in required_workspace_snippets:
|
||||
if snippet not in workspace_source:
|
||||
failures.append(f"Workspace focus guard missing snippet: {snippet}")
|
||||
|
||||
workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift"
|
||||
workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8")
|
||||
focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)"
|
||||
if focus_callback_snippet not in workspace_content_view_source:
|
||||
failures.append(
|
||||
"WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: focus-panel re-entrant guard regression checks failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: focus-panel re-entrant guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -9,6 +9,7 @@ This test checks for:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s
|
|||
return violations
|
||||
|
||||
|
||||
def check_command_palette_caret_tint(repo_root: Path) -> List[str]:
|
||||
"""Ensure command palette text inputs keep a white caret tint."""
|
||||
content_view = repo_root / "Sources" / "ContentView.swift"
|
||||
if not content_view.exists():
|
||||
return [f"Missing expected file: {content_view}"]
|
||||
|
||||
try:
|
||||
content = content_view.read_text()
|
||||
except Exception as e:
|
||||
return [f"Could not read {content_view}: {e}"]
|
||||
|
||||
checks = [
|
||||
(
|
||||
"search input",
|
||||
r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteSearchFocused\)",
|
||||
),
|
||||
(
|
||||
"rename input",
|
||||
r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteRenameFocused\)",
|
||||
),
|
||||
]
|
||||
|
||||
violations: List[str] = []
|
||||
for label, pattern in checks:
|
||||
match = re.search(pattern, content, flags=re.DOTALL)
|
||||
if not match:
|
||||
violations.append(
|
||||
f"Could not locate command palette {label} TextField block in Sources/ContentView.swift"
|
||||
)
|
||||
continue
|
||||
|
||||
body = match.group("body")
|
||||
if ".tint(.white)" not in body:
|
||||
violations.append(
|
||||
f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift"
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the lint checks."""
|
||||
repo_root = get_repo_root()
|
||||
|
|
@ -102,15 +145,18 @@ def main():
|
|||
print(f"Checking {len(swift_files)} Swift files for performance issues...")
|
||||
|
||||
# Check for auto-updating Text styles
|
||||
violations = check_autoupdating_text_styles(swift_files)
|
||||
style_violations = check_autoupdating_text_styles(swift_files)
|
||||
tint_violations = check_command_palette_caret_tint(repo_root)
|
||||
has_failures = False
|
||||
|
||||
if violations:
|
||||
if style_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Auto-updating Text styles found")
|
||||
print("=" * 60)
|
||||
print("These patterns cause continuous SwiftUI view updates and high CPU usage:")
|
||||
print()
|
||||
|
||||
for file_path, line_num, line in violations:
|
||||
for file_path, line_num, line in style_violations:
|
||||
rel_path = file_path.relative_to(repo_root)
|
||||
print(f" {rel_path}:{line_num}")
|
||||
print(f" {line}")
|
||||
|
|
@ -120,9 +166,23 @@ def main():
|
|||
print(" Instead of: Text(date, style: .time)")
|
||||
print(" Use: Text(date.formatted(date: .omitted, time: .shortened))")
|
||||
print()
|
||||
|
||||
if tint_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Command palette caret tint drifted")
|
||||
print("=" * 60)
|
||||
print("The command palette search and rename text fields must keep a white caret:")
|
||||
print()
|
||||
for message in tint_violations:
|
||||
print(f" {message}")
|
||||
print()
|
||||
print("FIX: Set command palette TextField tint modifiers to `.white`.")
|
||||
print()
|
||||
|
||||
if has_failures:
|
||||
return 1
|
||||
|
||||
print("✅ No auto-updating Text style patterns found")
|
||||
print("✅ No linted SwiftUI pattern regressions found")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
333
tests/test_open_wrapper.py
Executable file
333
tests/test_open_wrapper.py
Executable file
|
|
@ -0,0 +1,333 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression tests for Resources/bin/open.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_WRAPPER = ROOT / "Resources" / "bin" / "open"
|
||||
|
||||
|
||||
def make_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def read_log(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def run_wrapper(
|
||||
*,
|
||||
args: list[str],
|
||||
intercept_setting: str | None,
|
||||
legacy_open_setting: str | None = None,
|
||||
whitelist: str | None,
|
||||
fail_urls: list[str] | None = None,
|
||||
) -> tuple[list[str], list[str], int, str]:
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-open-wrapper-test-") as td:
|
||||
tmp = Path(td)
|
||||
wrapper = tmp / "open"
|
||||
shutil.copy2(SOURCE_WRAPPER, wrapper)
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
open_log = tmp / "open.log"
|
||||
cmux_log = tmp / "cmux.log"
|
||||
system_open = tmp / "system-open"
|
||||
defaults = tmp / "defaults"
|
||||
cmux = tmp / "cmux"
|
||||
|
||||
make_executable(
|
||||
system_open,
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\n' "$*" >> "$FAKE_OPEN_LOG"
|
||||
""",
|
||||
)
|
||||
|
||||
make_executable(
|
||||
defaults,
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" != "read" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
key="${3:-}"
|
||||
case "$key" in
|
||||
browserInterceptTerminalOpenCommandInCmuxBrowser)
|
||||
if [[ "${FAKE_DEFAULTS_INTERCEPT_OPEN+x}" == "x" ]]; then
|
||||
printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
browserOpenTerminalLinksInCmuxBrowser)
|
||||
if [[ "${FAKE_DEFAULTS_LEGACY_OPEN+x}" == "x" ]]; then
|
||||
printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
browserHostWhitelist)
|
||||
if [[ "${FAKE_DEFAULTS_WHITELIST+x}" == "x" ]]; then
|
||||
printf '%s' "$FAKE_DEFAULTS_WHITELIST"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
)
|
||||
|
||||
make_executable(
|
||||
cmux,
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\n' "$*" >> "$FAKE_CMUX_LOG"
|
||||
url=""
|
||||
for arg in "$@"; do
|
||||
url="$arg"
|
||||
done
|
||||
if [[ -n "${FAKE_CMUX_FAIL_URLS:-}" ]]; then
|
||||
IFS=',' read -r -a failures <<< "$FAKE_CMUX_FAIL_URLS"
|
||||
for fail_url in "${failures[@]}"; do
|
||||
if [[ "$url" == "$fail_url" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
""",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock"
|
||||
env["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.test"
|
||||
env["CMUX_OPEN_WRAPPER_SYSTEM_OPEN"] = str(system_open)
|
||||
env["CMUX_OPEN_WRAPPER_DEFAULTS"] = str(defaults)
|
||||
env["FAKE_OPEN_LOG"] = str(open_log)
|
||||
env["FAKE_CMUX_LOG"] = str(cmux_log)
|
||||
|
||||
if intercept_setting is None:
|
||||
env.pop("FAKE_DEFAULTS_INTERCEPT_OPEN", None)
|
||||
else:
|
||||
env["FAKE_DEFAULTS_INTERCEPT_OPEN"] = intercept_setting
|
||||
|
||||
if legacy_open_setting is None:
|
||||
env.pop("FAKE_DEFAULTS_LEGACY_OPEN", None)
|
||||
else:
|
||||
env["FAKE_DEFAULTS_LEGACY_OPEN"] = legacy_open_setting
|
||||
|
||||
if whitelist is None:
|
||||
env.pop("FAKE_DEFAULTS_WHITELIST", None)
|
||||
else:
|
||||
env["FAKE_DEFAULTS_WHITELIST"] = whitelist
|
||||
|
||||
if fail_urls:
|
||||
env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls)
|
||||
else:
|
||||
env.pop("FAKE_CMUX_FAIL_URLS", None)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/bash", str(wrapper), *args],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
return read_log(open_log), read_log(cmux_log), result.returncode, result.stderr.strip()
|
||||
|
||||
|
||||
def expect(condition: bool, message: str, failures: list[str]) -> None:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def test_toggle_disabled_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="0",
|
||||
whitelist="",
|
||||
)
|
||||
expect(code == 0, f"toggle off: wrapper exited {code}: {stderr}", failures)
|
||||
expect(cmux_log == [], f"toggle off: cmux should not be called, got {cmux_log}", failures)
|
||||
expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures)
|
||||
|
||||
|
||||
def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting=" FaLsE ",
|
||||
whitelist="",
|
||||
)
|
||||
expect(code == 0, f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", failures)
|
||||
expect(
|
||||
cmux_log == [],
|
||||
f"toggle off (case-insensitive): cmux should not be called, got {cmux_log}",
|
||||
failures,
|
||||
)
|
||||
expect(
|
||||
open_log == [url],
|
||||
f"toggle off (case-insensitive): expected system open [{url}], got {open_log}",
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
def test_whitelist_miss_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="localhost\n127.0.0.1",
|
||||
)
|
||||
expect(code == 0, f"whitelist miss: wrapper exited {code}: {stderr}", failures)
|
||||
expect(cmux_log == [], f"whitelist miss: cmux should not be called, got {cmux_log}", failures)
|
||||
expect(open_log == [url], f"whitelist miss: expected system open [{url}], got {open_log}", failures)
|
||||
|
||||
|
||||
def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None:
|
||||
url = "https://api.example.com/path?q=1"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="*.example.com",
|
||||
)
|
||||
expect(code == 0, f"whitelist match: wrapper exited {code}: {stderr}", failures)
|
||||
expect(open_log == [], f"whitelist match: system open should not be called, got {open_log}", failures)
|
||||
expect(cmux_log == [f"browser open {url}"], f"whitelist match: unexpected cmux log {cmux_log}", failures)
|
||||
|
||||
|
||||
def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None:
|
||||
good = "https://api.example.com"
|
||||
failed = "https://fail.example.com"
|
||||
external = "https://outside.test"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[good, failed, external],
|
||||
intercept_setting="1",
|
||||
whitelist="*.example.com",
|
||||
fail_urls=[failed],
|
||||
)
|
||||
expect(code == 0, f"partial failure: wrapper exited {code}: {stderr}", failures)
|
||||
expect(
|
||||
cmux_log == [f"browser open {good}", f"browser open {failed}"],
|
||||
f"partial failure: cmux log mismatch {cmux_log}",
|
||||
failures,
|
||||
)
|
||||
expect(
|
||||
open_log == [f"{failed} {external}"],
|
||||
f"partial failure: expected fallback for failed/external only, got {open_log}",
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting=None,
|
||||
legacy_open_setting="0",
|
||||
whitelist="",
|
||||
)
|
||||
expect(code == 0, f"legacy fallback: wrapper exited {code}: {stderr}", failures)
|
||||
expect(cmux_log == [], f"legacy fallback: cmux should not be called, got {cmux_log}", failures)
|
||||
expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures)
|
||||
|
||||
|
||||
def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting=None,
|
||||
legacy_open_setting=" Off ",
|
||||
whitelist="",
|
||||
)
|
||||
expect(code == 0, f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", failures)
|
||||
expect(
|
||||
cmux_log == [],
|
||||
f"legacy fallback (case-insensitive): cmux should not be called, got {cmux_log}",
|
||||
failures,
|
||||
)
|
||||
expect(
|
||||
open_log == [url],
|
||||
f"legacy fallback (case-insensitive): expected system open [{url}], got {open_log}",
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None:
|
||||
url = "HTTPS://api.example.com/path?q=1"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="*.example.com",
|
||||
)
|
||||
expect(code == 0, f"uppercase scheme: wrapper exited {code}: {stderr}", failures)
|
||||
expect(open_log == [], f"uppercase scheme: system open should not be called, got {open_log}", failures)
|
||||
expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures)
|
||||
|
||||
|
||||
def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None:
|
||||
url = "https://xn--bcher-kva.example/path"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="bücher.example",
|
||||
)
|
||||
expect(code == 0, f"unicode whitelist: wrapper exited {code}: {stderr}", failures)
|
||||
expect(open_log == [], f"unicode whitelist: system open should not be called, got {open_log}", failures)
|
||||
expect(cmux_log == [f"browser open {url}"], f"unicode whitelist: unexpected cmux log {cmux_log}", failures)
|
||||
|
||||
|
||||
def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None:
|
||||
url = "https://bücher.example/path"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="xn--bcher-kva.example",
|
||||
)
|
||||
expect(code == 0, f"punycode whitelist: wrapper exited {code}: {stderr}", failures)
|
||||
expect(open_log == [], f"punycode whitelist: system open should not be called, got {open_log}", failures)
|
||||
expect(cmux_log == [f"browser open {url}"], f"punycode whitelist: unexpected cmux log {cmux_log}", failures)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures: list[str] = []
|
||||
test_toggle_disabled_passthrough(failures)
|
||||
test_toggle_disabled_case_insensitive_passthrough(failures)
|
||||
test_whitelist_miss_passthrough(failures)
|
||||
test_whitelist_match_routes_to_cmux(failures)
|
||||
test_partial_failures_only_fallback_failed_urls(failures)
|
||||
test_legacy_toggle_fallback_passthrough(failures)
|
||||
test_legacy_toggle_fallback_case_insensitive_passthrough(failures)
|
||||
test_uppercase_scheme_routes_to_cmux(failures)
|
||||
test_unicode_whitelist_matches_punycode_url(failures)
|
||||
test_punycode_whitelist_matches_unicode_url(failures)
|
||||
|
||||
if failures:
|
||||
print("open wrapper regression tests failed:")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("open wrapper regression tests passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: unfocused workspace scrollback must persist across relaunchs in multi-window setups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cmux import cmux
|
||||
|
||||
|
||||
def _bundle_id(app_path: Path) -> str:
|
||||
info_path = app_path / "Contents" / "Info.plist"
|
||||
if not info_path.exists():
|
||||
raise RuntimeError(f"Missing Info.plist at {info_path}")
|
||||
with info_path.open("rb") as f:
|
||||
info = plistlib.load(f)
|
||||
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
|
||||
if not bundle_id:
|
||||
raise RuntimeError("Missing CFBundleIdentifier")
|
||||
return bundle_id
|
||||
|
||||
|
||||
def _snapshot_path(bundle_id: str) -> Path:
|
||||
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
|
||||
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json"
|
||||
|
||||
|
||||
def _sanitize_tag_slug(raw: str) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]+", "-", (raw or "").strip().lower())
|
||||
cleaned = re.sub(r"-+", "-", cleaned).strip("-")
|
||||
return cleaned or "agent"
|
||||
|
||||
|
||||
def _socket_candidates(app_path: Path, preferred: Path) -> list[Path]:
|
||||
candidates = [preferred]
|
||||
app_name = app_path.stem
|
||||
prefix = "cmux DEV "
|
||||
if app_name.startswith(prefix):
|
||||
tag = app_name[len(prefix):]
|
||||
slug = _sanitize_tag_slug(tag)
|
||||
candidates.append(Path(f"/tmp/cmux-debug-{slug}.sock"))
|
||||
deduped: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
key = str(candidate)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(candidate)
|
||||
return deduped
|
||||
|
||||
|
||||
def _socket_reachable(socket_path: Path) -> bool:
|
||||
if not socket_path.exists():
|
||||
return False
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(0.3)
|
||||
sock.connect(str(socket_path))
|
||||
sock.sendall(b"ping\n")
|
||||
data = sock.recv(1024)
|
||||
return b"PONG" in data
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _wait_for_socket(candidates: list[Path], timeout: float = 20.0) -> Path:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for candidate in candidates:
|
||||
if _socket_reachable(candidate):
|
||||
return candidate
|
||||
time.sleep(0.2)
|
||||
joined = ", ".join(str(path) for path in candidates)
|
||||
raise RuntimeError(f"Socket did not become reachable: {joined}")
|
||||
|
||||
|
||||
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if not _socket_reachable(socket_path):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
|
||||
|
||||
|
||||
def _kill_existing(app_path: Path) -> None:
|
||||
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
|
||||
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _launch(app_path: Path, preferred_socket_path: Path) -> Path:
|
||||
try:
|
||||
preferred_socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
subprocess.run(
|
||||
[
|
||||
"open",
|
||||
"-na",
|
||||
str(app_path),
|
||||
"--env",
|
||||
f"CMUX_SOCKET_PATH={preferred_socket_path}",
|
||||
"--env",
|
||||
"CMUX_ALLOW_SOCKET_OVERRIDE=1",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
resolved_socket_path = _wait_for_socket(_socket_candidates(app_path, preferred_socket_path))
|
||||
time.sleep(1.5)
|
||||
return resolved_socket_path
|
||||
|
||||
|
||||
def _quit(bundle_id: str, socket_path: Path) -> None:
|
||||
subprocess.run(
|
||||
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
_wait_for_socket_closed(socket_path)
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
time.sleep(0.8)
|
||||
|
||||
|
||||
def _connect(socket_path: Path) -> cmux:
|
||||
client = cmux(socket_path=str(socket_path))
|
||||
client.connect()
|
||||
if not client.ping():
|
||||
raise RuntimeError("ping failed")
|
||||
return client
|
||||
|
||||
|
||||
def _read_scrollback(client: cmux) -> str:
|
||||
return client._send_command("read_screen --scrollback")
|
||||
|
||||
|
||||
def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if marker in _read_scrollback(client):
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def _consume_visible_markers(client: cmux, remaining: set[str], timeout: float = 4.0) -> None:
|
||||
if not remaining:
|
||||
return
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline and remaining:
|
||||
text = _read_scrollback(client)
|
||||
matched = [marker for marker in remaining if marker in text]
|
||||
if matched:
|
||||
for marker in matched:
|
||||
remaining.discard(marker)
|
||||
if not remaining:
|
||||
return
|
||||
time.sleep(0.25)
|
||||
|
||||
|
||||
def _ensure_workspaces(client: cmux, count: int) -> None:
|
||||
while len(client.list_workspaces()) < count:
|
||||
client.new_workspace()
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def _list_windows(client: cmux) -> list[str]:
|
||||
response = client._send_command("list_windows")
|
||||
if response == "No windows":
|
||||
return []
|
||||
window_ids: list[str] = []
|
||||
for line in response.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.lstrip("* ").split(" ", 2)
|
||||
if len(parts) >= 2:
|
||||
window_ids.append(parts[1])
|
||||
return window_ids
|
||||
|
||||
|
||||
def _new_window(client: cmux) -> str:
|
||||
response = client._send_command("new_window")
|
||||
if not response.startswith("OK "):
|
||||
raise RuntimeError(f"new_window failed: {response}")
|
||||
return response.split(" ", 1)[1].strip()
|
||||
|
||||
|
||||
def _focus_window(client: cmux, window_id: str) -> None:
|
||||
response = client._send_command(f"focus_window {window_id}")
|
||||
if response != "OK":
|
||||
raise RuntimeError(f"focus_window failed for {window_id}: {response}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
|
||||
if not app_path_str:
|
||||
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
|
||||
return 0
|
||||
app_path = Path(app_path_str)
|
||||
if not app_path.exists():
|
||||
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
|
||||
return 0
|
||||
|
||||
bundle_id = _bundle_id(app_path)
|
||||
snapshot = _snapshot_path(bundle_id)
|
||||
# Keep the override path short enough for Darwin's Unix socket path limit.
|
||||
bundle_suffix = re.sub(r"[^A-Za-z0-9]", "", bundle_id)[-16:] or "bundle"
|
||||
socket_path = Path(f"/tmp/cmux-mw-restore-{bundle_suffix}.sock")
|
||||
|
||||
markers = {
|
||||
"w1_ws0": "CMUX_MW_RESTORE_W1_WS0",
|
||||
"w1_ws1": "CMUX_MW_RESTORE_W1_WS1",
|
||||
"w2_ws0": "CMUX_MW_RESTORE_W2_WS0",
|
||||
"w2_ws1": "CMUX_MW_RESTORE_W2_WS1",
|
||||
}
|
||||
failures: list[str] = []
|
||||
|
||||
_kill_existing(app_path)
|
||||
snapshot.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
# Launch 1: create 2 windows x 2 workspaces; write markers.
|
||||
socket_path = _launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
# Window 1 setup.
|
||||
_ensure_workspaces(client, 2)
|
||||
client.select_workspace(0)
|
||||
client.send(f"echo {markers['w1_ws0']}\n")
|
||||
if not _wait_for_marker(client, markers["w1_ws0"]):
|
||||
failures.append("missing marker for window1 workspace0 during setup")
|
||||
client.select_workspace(1)
|
||||
client.send(f"echo {markers['w1_ws1']}\n")
|
||||
if not _wait_for_marker(client, markers["w1_ws1"]):
|
||||
failures.append("missing marker for window1 workspace1 during setup")
|
||||
client.select_workspace(0) # leave workspace 1 unfocused in window 1
|
||||
|
||||
# Window 2 setup.
|
||||
_new_window(client)
|
||||
time.sleep(0.5)
|
||||
_ensure_workspaces(client, 2)
|
||||
client.select_workspace(0)
|
||||
client.send(f"echo {markers['w2_ws0']}\n")
|
||||
if not _wait_for_marker(client, markers["w2_ws0"]):
|
||||
failures.append("missing marker for window2 workspace0 during setup")
|
||||
client.select_workspace(1)
|
||||
client.send(f"echo {markers['w2_ws1']}\n")
|
||||
if not _wait_for_marker(client, markers["w2_ws1"]):
|
||||
failures.append("missing marker for window2 workspace1 during setup")
|
||||
client.select_workspace(0) # leave workspace 1 unfocused in window 2
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
|
||||
# Launch 2: immediate quit without focusing unfocused workspaces.
|
||||
socket_path = _launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
window_ids = _list_windows(client)
|
||||
if len(window_ids) < 2:
|
||||
failures.append(f"expected >=2 windows after first relaunch, got {len(window_ids)}")
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
|
||||
# Launch 3: verify all markers still present across windows/workspaces.
|
||||
socket_path = _launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
window_ids = _list_windows(client)
|
||||
if len(window_ids) < 2:
|
||||
failures.append(f"expected >=2 windows after second relaunch, got {len(window_ids)}")
|
||||
|
||||
remaining = set(markers.values())
|
||||
for window_id in window_ids:
|
||||
_focus_window(client, window_id)
|
||||
time.sleep(0.3)
|
||||
workspace_count = len(client.list_workspaces())
|
||||
for idx in range(min(workspace_count, 2)):
|
||||
client.select_workspace(idx)
|
||||
_consume_visible_markers(client, remaining, timeout=6.0)
|
||||
if not remaining:
|
||||
break
|
||||
if not remaining:
|
||||
break
|
||||
|
||||
if remaining:
|
||||
failures.append(f"missing markers after second relaunch: {sorted(remaining)}")
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
finally:
|
||||
_kill_existing(app_path)
|
||||
socket_path.unlink(missing_ok=True)
|
||||
snapshot.unlink(missing_ok=True)
|
||||
|
||||
if failures:
|
||||
print("FAIL:")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: multi-window unfocused workspaces survive repeated relaunch")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
229
tests/test_session_restore_unfocused_workspace_relaunch_cycle.py
Normal file
229
tests/test_session_restore_unfocused_workspace_relaunch_cycle.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: unfocused restored workspaces must survive a second relaunch.
|
||||
|
||||
Repro for the historical bug:
|
||||
1) Launch and save workspaces with marker scrollback.
|
||||
2) Relaunch, do not focus the non-selected workspaces, then quit again.
|
||||
3) Relaunch and verify marker scrollback still exists for every workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cmux import cmux
|
||||
|
||||
|
||||
def _bundle_id(app_path: Path) -> str:
|
||||
info_path = app_path / "Contents" / "Info.plist"
|
||||
if not info_path.exists():
|
||||
raise RuntimeError(f"Missing Info.plist at {info_path}")
|
||||
with info_path.open("rb") as f:
|
||||
info = plistlib.load(f)
|
||||
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
|
||||
if not bundle_id:
|
||||
raise RuntimeError("Missing CFBundleIdentifier")
|
||||
return bundle_id
|
||||
|
||||
|
||||
def _snapshot_path(bundle_id: str) -> Path:
|
||||
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
|
||||
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json"
|
||||
|
||||
|
||||
def _socket_reachable(socket_path: Path) -> bool:
|
||||
if not socket_path.exists():
|
||||
return False
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(0.3)
|
||||
sock.connect(str(socket_path))
|
||||
sock.sendall(b"ping\n")
|
||||
data = sock.recv(1024)
|
||||
return b"PONG" in data
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if _socket_reachable(socket_path):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"Socket did not become reachable: {socket_path}")
|
||||
|
||||
|
||||
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if not _socket_reachable(socket_path):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
|
||||
|
||||
|
||||
def _kill_existing(app_path: Path) -> None:
|
||||
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
|
||||
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _launch(app_path: Path, socket_path: Path) -> None:
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
subprocess.run(
|
||||
[
|
||||
"open",
|
||||
"-na",
|
||||
str(app_path),
|
||||
"--env",
|
||||
f"CMUX_SOCKET_PATH={socket_path}",
|
||||
"--env",
|
||||
"CMUX_ALLOW_SOCKET_OVERRIDE=1",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
_wait_for_socket(socket_path)
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
def _quit(bundle_id: str, socket_path: Path) -> None:
|
||||
subprocess.run(
|
||||
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
_wait_for_socket_closed(socket_path)
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
time.sleep(0.8)
|
||||
|
||||
|
||||
def _connect(socket_path: Path) -> cmux:
|
||||
client = cmux(socket_path=str(socket_path))
|
||||
client.connect()
|
||||
if not client.ping():
|
||||
raise RuntimeError("ping failed")
|
||||
return client
|
||||
|
||||
|
||||
def _read_scrollback(client: cmux) -> str:
|
||||
return client._send_command("read_screen --scrollback")
|
||||
|
||||
|
||||
def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if marker in _read_scrollback(client):
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
|
||||
if not app_path_str:
|
||||
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
|
||||
return 0
|
||||
app_path = Path(app_path_str)
|
||||
if not app_path.exists():
|
||||
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
|
||||
return 0
|
||||
|
||||
bundle_id = _bundle_id(app_path)
|
||||
snapshot = _snapshot_path(bundle_id)
|
||||
socket_path = Path(f"/tmp/cmux-session-restore-cycle-{bundle_id.replace('.', '-')}.sock")
|
||||
|
||||
markers = [f"CMUX_RESTORE_EDGE_{i}" for i in range(3)]
|
||||
failures: list[str] = []
|
||||
|
||||
_kill_existing(app_path)
|
||||
snapshot.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
# First launch: seed three workspaces with marker scrollback.
|
||||
_launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
while len(client.list_workspaces()) < 3:
|
||||
client.new_workspace()
|
||||
time.sleep(0.3)
|
||||
|
||||
for idx, marker in enumerate(markers):
|
||||
client.select_workspace(idx)
|
||||
time.sleep(0.4)
|
||||
client.send(f"echo {marker}\n")
|
||||
if not _wait_for_marker(client, marker, timeout=6.0):
|
||||
failures.append(f"setup marker missing in workspace {idx}: {marker}")
|
||||
|
||||
# Keep selected workspace deterministic.
|
||||
client.select_workspace(1)
|
||||
time.sleep(0.3)
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
|
||||
# Second launch: do not focus unfocused workspaces. Quit immediately.
|
||||
_launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
restored = client.list_workspaces()
|
||||
if len(restored) < 3:
|
||||
failures.append(f"expected >=3 workspaces after first relaunch, got {len(restored)}")
|
||||
selected_indices = [idx for idx, _wid, _title, selected in restored if selected]
|
||||
if selected_indices != [1]:
|
||||
failures.append(f"expected selected workspace index [1], got {selected_indices}")
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
|
||||
# Third launch: every workspace should still contain its marker.
|
||||
_launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
restored = client.list_workspaces()
|
||||
if len(restored) < 3:
|
||||
failures.append(f"expected >=3 workspaces after second relaunch, got {len(restored)}")
|
||||
|
||||
for idx, marker in enumerate(markers):
|
||||
client.select_workspace(idx)
|
||||
if not _wait_for_marker(client, marker, timeout=8.0):
|
||||
tail = "\n".join(_read_scrollback(client).splitlines()[-10:])
|
||||
failures.append(
|
||||
f"workspace {idx} missing marker {marker} after second relaunch; tail:\n{tail}"
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
finally:
|
||||
_kill_existing(app_path)
|
||||
socket_path.unlink(missing_ok=True)
|
||||
snapshot.unlink(missing_ok=True)
|
||||
|
||||
if failures:
|
||||
print("FAIL:")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: unfocused workspace scrollback survives repeated relaunch")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
62
tests/test_shell_scrollback_restore_color_replay.py
Normal file
62
tests/test_shell_scrollback_restore_color_replay.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: ANSI color escape bytes in replay content must be preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
|
||||
if not integration_script.exists():
|
||||
print(f"SKIP: missing zsh integration script at {integration_script}")
|
||||
return 0
|
||||
|
||||
base = Path("/tmp") / f"cmux_scrollback_color_replay_{os.getpid()}"
|
||||
try:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
replay_file = base / "replay.bin"
|
||||
replay_file.write_bytes(b"\x1b[31mRED\x1b[0m\n")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = str(base / "empty-bin")
|
||||
env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file)
|
||||
env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"FAIL: zsh exited non-zero rc={result.returncode}")
|
||||
if result.stderr:
|
||||
print(result.stderr.decode("utf-8", errors="replace").strip())
|
||||
return 1
|
||||
|
||||
output = (result.stdout or b"") + (result.stderr or b"")
|
||||
if b"\x1b[31mRED\x1b[0m" not in output:
|
||||
print("FAIL: ANSI color escape sequence not preserved in replay output")
|
||||
return 1
|
||||
|
||||
if replay_file.exists():
|
||||
print("FAIL: replay file was not deleted after replay")
|
||||
return 1
|
||||
|
||||
print("PASS: ANSI color escape sequence preserved during replay")
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: scrollback replay must not depend on PATH containing coreutils.
|
||||
|
||||
cmux can launch shells with PATH initially pointing at app resources. If replay
|
||||
relies on bare `cat`/`rm`, startup replay silently fails before user rc files
|
||||
restore PATH.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
|
||||
if not integration_script.exists():
|
||||
print(f"SKIP: missing zsh integration script at {integration_script}")
|
||||
return 0
|
||||
|
||||
base = Path("/tmp") / f"cmux_scrollback_restore_{os.getpid()}"
|
||||
try:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
replay_file = base / "replay.txt"
|
||||
replay_file.write_text("scrollback-line-1\nscrollback-line-2\n", encoding="utf-8")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = str(base / "empty-bin")
|
||||
env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file)
|
||||
env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"FAIL: zsh exited non-zero rc={result.returncode}")
|
||||
if result.stderr.strip():
|
||||
print(result.stderr.strip())
|
||||
return 1
|
||||
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
if "scrollback-line-1" not in output or "scrollback-line-2" not in output:
|
||||
print("FAIL: replay text was not printed during integration startup")
|
||||
return 1
|
||||
|
||||
if replay_file.exists():
|
||||
print("FAIL: replay file was not deleted after replay")
|
||||
return 1
|
||||
|
||||
print("PASS: scrollback replay works with minimal PATH")
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
106
tests/test_terminal_resize_portal_regressions.py
Normal file
106
tests/test_terminal_resize_portal_regressions.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for terminal tiny-pane resize/overflow fixes.
|
||||
|
||||
Guards the key invariants for issue #348:
|
||||
1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds.
|
||||
2) Surface sizing must prefer live bounds over stale pending values when available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
portal_path = root / "Sources" / "TerminalWindowPortal.swift"
|
||||
portal_source = portal_path.read_text(encoding="utf-8")
|
||||
|
||||
if "hostView.layer?.masksToBounds = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView layer clipping")
|
||||
if "hostView.postsFrameChangedNotifications = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications")
|
||||
if "hostView.postsBoundsChangedNotifications = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications")
|
||||
|
||||
if "private func synchronizeLayoutHierarchy()" not in portal_source:
|
||||
failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()")
|
||||
if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source:
|
||||
failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()")
|
||||
if "hostedView.reconcileGeometryNow()" not in extract_block(
|
||||
portal_source,
|
||||
"func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)",
|
||||
):
|
||||
failures.append("bind() no longer pre-reconciles hosted geometry before attach")
|
||||
|
||||
sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)")
|
||||
for required in [
|
||||
"let hostBounds = hostView.bounds",
|
||||
"let clampedFrame = frameInHost.intersection(hostBounds)",
|
||||
"let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost",
|
||||
"scheduleDeferredFullSynchronizeAll()",
|
||||
"hostedView.reconcileGeometryNow()",
|
||||
"hostedView.refreshSurfaceNow()",
|
||||
]:
|
||||
if required not in sync_block:
|
||||
failures.append(f"terminal portal sync missing: {required}")
|
||||
|
||||
terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift"
|
||||
terminal_view_source = terminal_view_path.read_text(encoding="utf-8")
|
||||
|
||||
resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize")
|
||||
bounds_index = resolved_block.find("let currentBounds = bounds.size")
|
||||
pending_index = resolved_block.find("if let pending = pendingSurfaceSize")
|
||||
if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index:
|
||||
failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize")
|
||||
|
||||
update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)")
|
||||
if "let size = resolvedSurfaceSize(preferred: size)" not in update_block:
|
||||
failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()")
|
||||
|
||||
if failures:
|
||||
print("FAIL: terminal resize/portal regression guards failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: terminal resize/portal regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -918,6 +918,27 @@ class cmux:
|
|||
def activate_app(self) -> None:
|
||||
self._call("debug.app.activate")
|
||||
|
||||
def open_command_palette_rename_tab_input(self, window_id: Optional[str] = None) -> None:
|
||||
params: Dict[str, Any] = {}
|
||||
if window_id is not None:
|
||||
params["window_id"] = str(window_id)
|
||||
self._call("debug.command_palette.rename_tab.open", params)
|
||||
|
||||
def command_palette_results(self, window_id: str, limit: int = 20) -> dict:
|
||||
res = self._call(
|
||||
"debug.command_palette.results",
|
||||
{"window_id": str(window_id), "limit": int(limit)},
|
||||
) or {}
|
||||
return dict(res)
|
||||
|
||||
def command_palette_rename_select_all(self) -> bool:
|
||||
res = self._call("debug.command_palette.rename_input.select_all") or {}
|
||||
return bool(res.get("enabled"))
|
||||
|
||||
def set_command_palette_rename_select_all(self, enabled: bool) -> bool:
|
||||
res = self._call("debug.command_palette.rename_input.select_all", {"enabled": bool(enabled)}) or {}
|
||||
return bool(res.get("enabled"))
|
||||
|
||||
def is_terminal_focused(self, panel: Union[str, int]) -> bool:
|
||||
sid = self._resolve_surface_id(panel)
|
||||
res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {}
|
||||
|
|
|
|||
158
tests_v2/test_command_palette_backspace_go_back.py
Normal file
158
tests_v2/test_command_palette_backspace_go_back.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: backspace on empty rename input returns to command list.
|
||||
|
||||
Coverage:
|
||||
- First backspace clears selected rename text.
|
||||
- Second backspace on empty rename input navigates back to command list mode.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client, window_id):
|
||||
return client.command_palette_results(window_id, limit=20)
|
||||
|
||||
|
||||
def _rename_selection(client, window_id):
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _int_or(value, default):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return int(default)
|
||||
|
||||
|
||||
def _open_rename_input(client, window_id):
|
||||
client.activate_app()
|
||||
client.focus_window(window_id)
|
||||
time.sleep(0.1)
|
||||
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close before setup",
|
||||
)
|
||||
|
||||
client.open_command_palette_rename_tab_input(window_id=window_id)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="command palette failed to open",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "rename_input",
|
||||
message="command palette did not enter rename input mode",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
window_id = client.current_window()
|
||||
|
||||
original_select_all = client.command_palette_rename_select_all()
|
||||
|
||||
try:
|
||||
client.set_command_palette_rename_select_all(True)
|
||||
_open_rename_input(client, window_id)
|
||||
|
||||
_wait_until(
|
||||
lambda: bool(_rename_selection(client, window_id).get("focused")),
|
||||
message="rename input did not focus",
|
||||
)
|
||||
|
||||
selection = _rename_selection(client, window_id)
|
||||
text_length = _int_or(selection.get("text_length"), 0)
|
||||
selection_location = _int_or(selection.get("selection_location"), -1)
|
||||
selection_length = _int_or(selection.get("selection_length"), -1)
|
||||
if not (
|
||||
text_length > 0
|
||||
and selection_location in (-1, 0)
|
||||
and selection_length == text_length
|
||||
):
|
||||
raise cmuxError(
|
||||
"rename input was not select-all on open: "
|
||||
f"text_length={text_length} selection=({selection_location}, {selection_length})"
|
||||
)
|
||||
|
||||
client._call(
|
||||
"debug.command_palette.rename_input.delete_backward",
|
||||
{"window_id": window_id},
|
||||
)
|
||||
|
||||
first_backspace_cleared = False
|
||||
last_selection = {}
|
||||
for _ in range(40):
|
||||
last_selection = _rename_selection(client, window_id)
|
||||
if _int_or(last_selection.get("text_length"), -1) == 0:
|
||||
first_backspace_cleared = True
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not first_backspace_cleared:
|
||||
raise cmuxError(
|
||||
"first backspace did not clear rename input: "
|
||||
f"selection={last_selection} results={_palette_results(client, window_id)}"
|
||||
)
|
||||
after_first = _palette_results(client, window_id)
|
||||
if str(after_first.get("mode") or "") != "rename_input":
|
||||
raise cmuxError(f"palette exited rename mode too early after first backspace: {after_first}")
|
||||
|
||||
client._call(
|
||||
"debug.command_palette.rename_input.delete_backward",
|
||||
{"window_id": window_id},
|
||||
)
|
||||
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
|
||||
message="second backspace on empty input did not return to commands mode",
|
||||
)
|
||||
|
||||
if not _palette_visible(client, window_id):
|
||||
raise cmuxError("palette closed unexpectedly instead of navigating back to command list")
|
||||
|
||||
finally:
|
||||
try:
|
||||
client.set_command_palette_rename_select_all(original_select_all)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close during cleanup",
|
||||
)
|
||||
|
||||
print("PASS: backspace on empty rename input navigates back to command list")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
97
tests_v2/test_command_palette_focus.py
Normal file
97
tests_v2/test_command_palette_focus.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: opening the command palette must move focus away from terminal.
|
||||
|
||||
Why: if terminal remains first responder under the palette, typing goes into the shell
|
||||
instead of the palette search field.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _focused_surface_id(client: cmux) -> str:
|
||||
surfaces = client.list_surfaces()
|
||||
for _, sid, focused in surfaces:
|
||||
if focused:
|
||||
return sid
|
||||
raise cmuxError(f"No focused surface in list_surfaces: {surfaces}")
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(res.get("visible"))
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 3.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
token = "CMUX_PALETTE_FOCUS_PROBE_9412"
|
||||
restore_token = "CMUX_PALETTE_RESTORE_PROBE_7731"
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.new_workspace()
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
panel_id = _focused_surface_id(client)
|
||||
_wait_until(
|
||||
lambda: client.is_terminal_focused(panel_id),
|
||||
timeout_s=5.0,
|
||||
message=f"terminal never became focused for panel {panel_id}",
|
||||
)
|
||||
|
||||
pre_text = client.read_terminal_text(panel_id)
|
||||
|
||||
# Open palette via debug method and assert terminal focus drops.
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
timeout_s=3.0,
|
||||
message="command palette did not open",
|
||||
)
|
||||
|
||||
# Typing now should target palette input, not the terminal.
|
||||
client.simulate_type(token)
|
||||
time.sleep(0.15)
|
||||
post_text = client.read_terminal_text(panel_id)
|
||||
|
||||
if token in post_text and token not in pre_text:
|
||||
raise cmuxError("typed probe text leaked into terminal while palette is open")
|
||||
|
||||
# Close palette and ensure focus returns to previously-focused terminal.
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
timeout_s=3.0,
|
||||
message="command palette did not close",
|
||||
)
|
||||
|
||||
client.simulate_type(restore_token)
|
||||
time.sleep(0.15)
|
||||
restore_text = client.read_terminal_text(panel_id)
|
||||
if restore_token not in restore_text:
|
||||
raise cmuxError("terminal did not receive typing after closing command palette")
|
||||
|
||||
print("PASS: command palette steals and restores terminal focus")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
125
tests_v2/test_command_palette_focus_lock_workspace_spawn.py
Normal file
125
tests_v2/test_command_palette_focus_lock_workspace_spawn.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command palette focus must remain stable while a new workspace shell spawns.
|
||||
|
||||
Why: when a terminal steals first responder during workspace bootstrap, the command-palette
|
||||
search field can re-focus with full selection, so the next keystroke replaces the whole query.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _palette_input_selection(client: cmux, window_id: str) -> dict:
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _close_palette_if_open(client: cmux, window_id: str) -> None:
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close",
|
||||
)
|
||||
|
||||
|
||||
def _assert_caret_at_end(selection: dict, context: str) -> None:
|
||||
if not selection.get("focused"):
|
||||
raise cmuxError(f"{context}: palette input is not focused")
|
||||
text_length = int(selection.get("text_length") or 0)
|
||||
selection_location = int(selection.get("selection_location") or 0)
|
||||
selection_length = int(selection.get("selection_length") or 0)
|
||||
if selection_location != text_length or selection_length != 0:
|
||||
raise cmuxError(
|
||||
f"{context}: expected caret-at-end, got location={selection_location}, "
|
||||
f"length={selection_length}, text_length={text_length}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
_close_palette_if_open(client, window_id)
|
||||
workspace_count_before = len(client.list_workspaces(window_id=window_id))
|
||||
|
||||
client.simulate_shortcut("cmd+shift+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+shift+p did not open command palette",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
|
||||
message="palette did not open in commands mode",
|
||||
)
|
||||
|
||||
selection = _palette_input_selection(client, window_id)
|
||||
_assert_caret_at_end(selection, "initial state")
|
||||
|
||||
client.new_workspace(window_id=window_id)
|
||||
_wait_until(
|
||||
lambda: len(client.list_workspaces(window_id=window_id)) >= workspace_count_before + 1,
|
||||
message="workspace.create did not add a new workspace",
|
||||
)
|
||||
|
||||
# Sample across shell bootstrap; focus and caret should stay stable.
|
||||
sample_deadline = time.time() + 2.0
|
||||
while time.time() < sample_deadline:
|
||||
selection = _palette_input_selection(client, window_id)
|
||||
_assert_caret_at_end(selection, "after workspace spawn")
|
||||
time.sleep(0.01)
|
||||
|
||||
client.simulate_type("focuslock")
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
|
||||
message="typing after workspace spawn switched palette out of commands mode",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: "focuslock" in str(_palette_results(client, window_id).get("query") or "").lower(),
|
||||
message="typing after workspace spawn did not append into command query",
|
||||
)
|
||||
|
||||
print("PASS: command palette keeps focus/caret during workspace shell spawn")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
133
tests_v2/test_command_palette_fuzzy_ranking.py
Normal file
133
tests_v2/test_command_palette_fuzzy_ranking.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command palette fuzzy ranking for rename commands.
|
||||
|
||||
Validates:
|
||||
- Typing `rename` is captured by the palette query.
|
||||
- The top-ranked command is a rename command.
|
||||
- Pressing Enter opens rename input (instead of running an unrelated command).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
RENAME_COMMAND_IDS = {"palette.renameTab", "palette.renameWorkspace"}
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s=5.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _rename_input_selection(client: cmux, window_id: str) -> dict:
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 10) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
_set_palette_visible(client, window_id, True)
|
||||
|
||||
# Force command mode query regardless transient field-editor selection state.
|
||||
time.sleep(0.2)
|
||||
client.simulate_shortcut("cmd+a")
|
||||
client.simulate_type(">rename")
|
||||
_wait_until(
|
||||
lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="palette query did not update to 'rename'",
|
||||
)
|
||||
|
||||
payload = _palette_results(client, window_id, limit=12)
|
||||
rows = payload.get("results") or []
|
||||
if not rows:
|
||||
raise cmuxError(f"palette returned no results for rename query: {payload}")
|
||||
|
||||
top = rows[0] or {}
|
||||
top_id = str(top.get("command_id") or "")
|
||||
top_title = str(top.get("title") or "")
|
||||
if top_id not in RENAME_COMMAND_IDS:
|
||||
titles = [str(row.get("title") or "") for row in rows]
|
||||
raise cmuxError(
|
||||
f"unexpected top result for 'rename': id={top_id!r} title={top_title!r} results={titles}"
|
||||
)
|
||||
|
||||
client.simulate_shortcut("cmd+a")
|
||||
client.simulate_type(">retab")
|
||||
_wait_until(
|
||||
lambda: "retab" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="palette query did not update to 'retab'",
|
||||
)
|
||||
|
||||
retab_payload = _palette_results(client, window_id, limit=12)
|
||||
retab_rows = retab_payload.get("results") or []
|
||||
if not retab_rows:
|
||||
raise cmuxError(f"palette returned no results for retab query: {retab_payload}")
|
||||
top_retabs = [str(row.get("command_id") or "") for row in retab_rows[:3]]
|
||||
if "palette.renameTab" not in top_retabs:
|
||||
raise cmuxError(
|
||||
f"'retab' did not rank Rename Tab near top: top3={top_retabs} rows={retab_rows}"
|
||||
)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id)
|
||||
and bool(_rename_input_selection(client, window_id).get("focused")),
|
||||
message="Enter did not open rename input for top rename result",
|
||||
)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
print("PASS: command palette fuzzy ranking prioritizes rename commands")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
194
tests_v2/test_command_palette_modes.py
Normal file
194
tests_v2/test_command_palette_modes.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: VSCode-like command palette modes.
|
||||
|
||||
Validates:
|
||||
- Cmd+Shift+P opens commands mode (leading '>' semantics).
|
||||
- Cmd+P opens workspace/tab switcher mode.
|
||||
- Repeating Cmd+Shift+P or Cmd+P toggles visibility (open/close).
|
||||
- Switcher search can jump to another workspace by pressing Enter.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _palette_input_selection(client: cmux, window_id: str) -> dict:
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _wait_for_palette_input_caret_at_end(
|
||||
client: cmux,
|
||||
window_id: str,
|
||||
expected_text_length: int,
|
||||
message: str,
|
||||
timeout_s: float = 1.2,
|
||||
) -> None:
|
||||
def _matches() -> bool:
|
||||
selection = _palette_input_selection(client, window_id)
|
||||
if not selection.get("focused"):
|
||||
return False
|
||||
text_length = int(selection.get("text_length") or 0)
|
||||
selection_location = int(selection.get("selection_location") or 0)
|
||||
selection_length = int(selection.get("selection_length") or 0)
|
||||
return (
|
||||
text_length == expected_text_length
|
||||
and selection_location == expected_text_length
|
||||
and selection_length == 0
|
||||
)
|
||||
|
||||
_wait_until(_matches, timeout_s=timeout_s, message=message)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
timeout_s=3.0,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
ws_a = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(ws_a)
|
||||
client.rename_workspace("alpha-workspace", workspace=ws_a)
|
||||
|
||||
ws_b = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(ws_b)
|
||||
client.rename_workspace("bravo-workspace", workspace=ws_b)
|
||||
|
||||
client.select_workspace(ws_a)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace() == ws_a,
|
||||
message="failed to select workspace alpha before switcher jump",
|
||||
)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
# Cmd+P: switcher mode.
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open command palette",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
time.sleep(0.2)
|
||||
client.simulate_type("bravo")
|
||||
_wait_until(
|
||||
lambda: "bravo" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="switcher query did not include bravo",
|
||||
)
|
||||
switched_rows = (_palette_results(client, window_id, limit=12).get("results") or [])
|
||||
if not switched_rows:
|
||||
raise cmuxError("switcher returned no rows for workspace query")
|
||||
top_id = str((switched_rows[0] or {}).get("command_id") or "")
|
||||
if not top_id.startswith("switcher."):
|
||||
raise cmuxError(f"expected switcher row on top for cmd+p query, got: {switched_rows[0]}")
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="palette did not close after selecting switcher row",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace() == ws_b,
|
||||
message="Enter on switcher result did not move to target workspace",
|
||||
)
|
||||
|
||||
# Cmd+Shift+P: commands mode.
|
||||
client.simulate_shortcut("cmd+shift+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+shift+p did not open command palette",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
|
||||
message="cmd+shift+p did not open commands mode",
|
||||
)
|
||||
_wait_for_palette_input_caret_at_end(
|
||||
client,
|
||||
window_id,
|
||||
expected_text_length=1,
|
||||
message="cmd+shift+p should prefill '>' with caret at end (not selected)",
|
||||
)
|
||||
|
||||
command_rows = (_palette_results(client, window_id, limit=8).get("results") or [])
|
||||
if not command_rows:
|
||||
raise cmuxError("commands mode returned no rows")
|
||||
top_command_id = str((command_rows[0] or {}).get("command_id") or "")
|
||||
if not top_command_id.startswith("palette."):
|
||||
raise cmuxError(f"expected command row in commands mode, got: {command_rows[0]}")
|
||||
|
||||
# Repeating either shortcut should toggle visibility.
|
||||
client.simulate_shortcut("cmd+shift+p")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="second cmd+shift+p did not close the command palette",
|
||||
)
|
||||
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id)
|
||||
and str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not reopen switcher mode after toggle-close",
|
||||
)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="second cmd+p did not close the command palette",
|
||||
)
|
||||
|
||||
print("PASS: command palette cmd+p/cmd+shift+p open correct modes and toggle on repeat")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
143
tests_v2/test_command_palette_navigation_keys.py
Normal file
143
tests_v2/test_command_palette_navigation_keys.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command palette list navigation keys.
|
||||
|
||||
Validates:
|
||||
- Down: ArrowDown, Ctrl+N, Ctrl+J
|
||||
- Up: ArrowUp, Ctrl+P, Ctrl+K
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(
|
||||
predicate,
|
||||
timeout_s: float = 4.0,
|
||||
interval_s: float = 0.05,
|
||||
message: str = "timeout",
|
||||
) -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(res.get("visible"))
|
||||
|
||||
|
||||
def _palette_selected_index(client: cmux, window_id: str) -> int:
|
||||
res = client._call("debug.command_palette.selection", {"window_id": window_id}) or {}
|
||||
return int(res.get("selected_index") or 0)
|
||||
|
||||
|
||||
def _has_focused_surface(client: cmux) -> bool:
|
||||
try:
|
||||
return any(bool(row[2]) for row in client.list_surfaces())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_palette_with_query(client: cmux, window_id: str, query: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
_set_palette_visible(client, window_id, True)
|
||||
client.simulate_type(query)
|
||||
_wait_until(
|
||||
lambda: _palette_selected_index(client, window_id) == 0,
|
||||
message="palette selected index did not reset to zero",
|
||||
)
|
||||
|
||||
|
||||
def _assert_move(client: cmux, window_id: str, combo: str, start_index: int, expected_index: int) -> None:
|
||||
_open_palette_with_query(client, window_id, "new")
|
||||
for _ in range(start_index):
|
||||
client.simulate_shortcut("down")
|
||||
_wait_until(
|
||||
lambda: _palette_selected_index(client, window_id) == start_index,
|
||||
message=f"failed to seed start index {start_index}",
|
||||
)
|
||||
|
||||
client.simulate_shortcut(combo)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id)
|
||||
and _palette_selected_index(client, window_id) == expected_index,
|
||||
message=f"{combo} did not move selection from {start_index} to {expected_index}",
|
||||
)
|
||||
|
||||
|
||||
def _assert_can_navigate_past_ten_results(client: cmux, window_id: str) -> None:
|
||||
_open_palette_with_query(client, window_id, "")
|
||||
|
||||
for _ in range(12):
|
||||
client.simulate_shortcut("down")
|
||||
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id)
|
||||
and _palette_selected_index(client, window_id) >= 10,
|
||||
message="selection did not move past index 9 (results may be capped)",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
client.new_workspace()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
# Isolate this test to one window so stale palettes in other windows
|
||||
# cannot steal navigation notifications.
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
_wait_until(
|
||||
lambda: _has_focused_surface(client),
|
||||
timeout_s=5.0,
|
||||
message="no focused surface available for command palette context",
|
||||
)
|
||||
|
||||
for combo in ("down", "ctrl+n", "ctrl+j"):
|
||||
_assert_move(client, window_id, combo, start_index=0, expected_index=1)
|
||||
|
||||
for combo in ("up", "ctrl+p", "ctrl+k"):
|
||||
_assert_move(client, window_id, combo, start_index=1, expected_index=0)
|
||||
|
||||
_assert_can_navigate_past_ten_results(client, window_id)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
print("PASS: command palette navigation keys and uncapped result navigation")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
106
tests_v2/test_command_palette_rename_enter.py
Normal file
106
tests_v2/test_command_palette_rename_enter.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette rename flow responds to Enter.
|
||||
|
||||
Coverage:
|
||||
- Enter in rename input applies the new tab name and closes the palette.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _rename_input_selection(client, window_id):
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _focused_pane_id(client):
|
||||
panes = client.list_panes()
|
||||
focused = [row for row in panes if bool(row[3])]
|
||||
if not focused:
|
||||
raise cmuxError(f"no focused pane: {panes}")
|
||||
return str(focused[0][1])
|
||||
|
||||
|
||||
def _selected_surface_title(client, pane_id):
|
||||
rows = client.list_pane_surfaces(pane_id)
|
||||
selected = [row for row in rows if bool(row[3])]
|
||||
if not selected:
|
||||
raise cmuxError(f"no selected surface in pane {pane_id}: {rows}")
|
||||
return str(selected[0][2])
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
pane_id = _focused_pane_id(client)
|
||||
rename_to = f"rename-enter-{int(time.time())}"
|
||||
|
||||
client.open_command_palette_rename_tab_input(window_id=window_id)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="command palette did not open",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: bool(_rename_input_selection(client, window_id).get("focused")),
|
||||
message="rename input did not focus",
|
||||
)
|
||||
|
||||
client.simulate_type(rename_to)
|
||||
time.sleep(0.1)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="Enter did not apply rename and close palette",
|
||||
)
|
||||
|
||||
new_title = _selected_surface_title(client, pane_id)
|
||||
if new_title != rename_to:
|
||||
raise cmuxError(f"rename not applied: expected '{rename_to}', got '{new_title}'")
|
||||
|
||||
print("PASS: command-palette rename flow accepts Enter in input")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
185
tests_v2/test_command_palette_rename_select_all.py
Normal file
185
tests_v2/test_command_palette_rename_select_all.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette rename input keeps select-all on interaction.
|
||||
|
||||
Coverage:
|
||||
- With select-all setting enabled, rename input selects all existing text
|
||||
immediately and stays selected after interaction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _rename_input_selection(client, window_id):
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _rename_select_all_setting(client):
|
||||
payload = client._call("debug.command_palette.rename_input.select_all", {}) or {}
|
||||
return bool(payload.get("enabled"))
|
||||
|
||||
|
||||
def _set_rename_select_all_setting(client, enabled):
|
||||
payload = client._call(
|
||||
"debug.command_palette.rename_input.select_all",
|
||||
{"enabled": bool(enabled)},
|
||||
) or {}
|
||||
return bool(payload.get("enabled"))
|
||||
|
||||
|
||||
def _wait_for_rename_selection(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all,
|
||||
message,
|
||||
timeout_s=0.6,
|
||||
):
|
||||
def _matches():
|
||||
selection = _rename_input_selection(client, window_id)
|
||||
if not selection.get("focused"):
|
||||
return False
|
||||
text_length = int(selection.get("text_length") or 0)
|
||||
selection_location = int(selection.get("selection_location") or 0)
|
||||
selection_length = int(selection.get("selection_length") or 0)
|
||||
if expect_select_all:
|
||||
return text_length > 0 and selection_location == 0 and selection_length == text_length
|
||||
return selection_location == text_length and selection_length == 0
|
||||
|
||||
_wait_until(_matches, timeout_s=timeout_s, message=message)
|
||||
|
||||
|
||||
def _exercise_rename_selection_setting(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all,
|
||||
cycles,
|
||||
label,
|
||||
):
|
||||
for cycle in range(cycles):
|
||||
_open_rename_tab_input(client, window_id)
|
||||
_wait_for_rename_selection(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all=expect_select_all,
|
||||
timeout_s=0.4,
|
||||
message=(
|
||||
f"{label}: rename input not ready with expected selection "
|
||||
f"on open (cycle {cycle + 1}/{cycles})"
|
||||
),
|
||||
)
|
||||
client._call("debug.command_palette.rename_input.interact", {"window_id": window_id})
|
||||
_wait_for_rename_selection(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all=expect_select_all,
|
||||
timeout_s=0.6,
|
||||
message=(
|
||||
f"{label}: rename input selection changed after interaction "
|
||||
f"(cycle {cycle + 1}/{cycles})"
|
||||
),
|
||||
)
|
||||
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message=f"{label}: command palette failed to close (cycle {cycle + 1}/{cycles})",
|
||||
)
|
||||
|
||||
|
||||
def _open_rename_tab_input(client, window_id):
|
||||
client.activate_app()
|
||||
client.focus_window(window_id)
|
||||
time.sleep(0.1)
|
||||
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close before setup",
|
||||
)
|
||||
|
||||
client.open_command_palette_rename_tab_input(window_id=window_id)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="command palette failed to open rename-tab input",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
original_select_all = _rename_select_all_setting(client)
|
||||
|
||||
workspace_id = client.new_workspace()
|
||||
client.select_workspace(workspace_id)
|
||||
client.rename_workspace("SeedName", workspace_id)
|
||||
time.sleep(0.25)
|
||||
window_id = client.current_window()
|
||||
|
||||
try:
|
||||
stress_cycles = 8
|
||||
|
||||
# ON: immediate select-all and interaction-preserved select-all.
|
||||
_set_rename_select_all_setting(client, True)
|
||||
_exercise_rename_selection_setting(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all=True,
|
||||
cycles=stress_cycles,
|
||||
label="select-all enabled",
|
||||
)
|
||||
|
||||
# OFF: immediate caret-at-end and interaction-preserved caret-at-end.
|
||||
_set_rename_select_all_setting(client, False)
|
||||
_exercise_rename_selection_setting(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all=False,
|
||||
cycles=stress_cycles,
|
||||
label="select-all disabled",
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
_set_rename_select_all_setting(client, original_select_all)
|
||||
except Exception:
|
||||
pass
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close during cleanup",
|
||||
)
|
||||
|
||||
print("PASS: command-palette rename input obeys select-all setting (on/off)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
122
tests_v2/test_command_palette_search_action_sync.py
Normal file
122
tests_v2/test_command_palette_search_action_sync.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette search updates rows and executed action in sync.
|
||||
|
||||
Why: if query replacement doesn't fully refresh the result list, the top row text
|
||||
can lag behind the action executed on Enter.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _set_palette_visible(client, window_id, visible):
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"command palette did not become visible={visible}",
|
||||
)
|
||||
|
||||
|
||||
def _palette_results(client, window_id, limit=10):
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _palette_input_selection(client, window_id):
|
||||
# Shared field-editor probe used by other command palette regressions.
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
_set_palette_visible(client, window_id, True)
|
||||
_wait_until(
|
||||
lambda: bool(_palette_input_selection(client, window_id).get("focused")),
|
||||
message="palette search input did not focus",
|
||||
)
|
||||
|
||||
client.simulate_shortcut("cmd+a")
|
||||
client.simulate_type(">open")
|
||||
_wait_until(
|
||||
lambda: "open" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="palette query did not become 'open'",
|
||||
)
|
||||
|
||||
before = _palette_results(client, window_id, limit=8)
|
||||
before_rows = before.get("results") or []
|
||||
if not before_rows:
|
||||
raise cmuxError(f"no results for 'open': {before}")
|
||||
if str(before_rows[0].get("command_id") or "") != "palette.terminalOpenDirectory":
|
||||
raise cmuxError(f"unexpected top command for 'open': {before_rows[0]}")
|
||||
|
||||
client.simulate_shortcut("cmd+a")
|
||||
client.simulate_type(">rename")
|
||||
_wait_until(
|
||||
lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="palette query did not become 'rename' after replacement",
|
||||
)
|
||||
after = _palette_results(client, window_id, limit=8)
|
||||
after_rows = after.get("results") or []
|
||||
if not after_rows:
|
||||
raise cmuxError(f"no results for 'rename' after replacement: {after}")
|
||||
top_after = str(after_rows[0].get("command_id") or "")
|
||||
if top_after not in {"palette.renameWorkspace", "palette.renameTab"}:
|
||||
raise cmuxError(f"top result did not update to rename command after replacement: {after_rows[0]}")
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: bool(_palette_input_selection(client, window_id).get("focused")),
|
||||
message="Enter did not trigger renamed top command input",
|
||||
)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
print("PASS: command-palette search replacement keeps row text/action in sync")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
121
tests_v2/test_command_palette_search_typing_stability.py
Normal file
121
tests_v2/test_command_palette_search_typing_stability.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette search typing should not reset selection.
|
||||
|
||||
Why: if focus-lock logic repeatedly re-focuses the text field, typing behaves
|
||||
like Cmd+A is being spammed and each character replaces the previous query.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s=4.0, interval_s=0.04, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_input_selection(client, window_id):
|
||||
# Uses the shared field-editor probe; works for search and rename modes.
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _wait_for_input_state(client, window_id, expected_text_length, message, timeout_s=0.8):
|
||||
def _matches():
|
||||
selection = _palette_input_selection(client, window_id)
|
||||
if not selection.get("focused"):
|
||||
return False
|
||||
text_length = int(selection.get("text_length") or 0)
|
||||
selection_location = int(selection.get("selection_location") or 0)
|
||||
selection_length = int(selection.get("selection_length") or 0)
|
||||
return (
|
||||
text_length == expected_text_length
|
||||
and selection_location == expected_text_length
|
||||
and selection_length == 0
|
||||
)
|
||||
|
||||
_wait_until(_matches, timeout_s=timeout_s, message=message)
|
||||
|
||||
|
||||
def _close_palette_if_open(client, window_id):
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close",
|
||||
)
|
||||
|
||||
|
||||
def _open_palette(client, window_id):
|
||||
_close_palette_if_open(client, window_id)
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="command palette failed to open",
|
||||
)
|
||||
_wait_for_input_state(
|
||||
client,
|
||||
window_id,
|
||||
expected_text_length=0,
|
||||
message="search input did not focus with empty query",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
|
||||
# Keep a single active window for deterministic first-responder behavior.
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
probe = "typingstability"
|
||||
cycles = 4
|
||||
for cycle in range(cycles):
|
||||
_open_palette(client, window_id)
|
||||
for idx, ch in enumerate(probe, start=1):
|
||||
client.simulate_type(ch)
|
||||
_wait_for_input_state(
|
||||
client,
|
||||
window_id,
|
||||
expected_text_length=idx,
|
||||
timeout_s=0.7,
|
||||
message=(
|
||||
f"search typing did not accumulate at cycle {cycle + 1}/{cycles}, "
|
||||
f"char {idx}/{len(probe)}"
|
||||
),
|
||||
)
|
||||
_close_palette_if_open(client, window_id)
|
||||
|
||||
print("PASS: command-palette search typing accumulates text without select-all churn")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
119
tests_v2/test_command_palette_shortcut_hint_sync.py
Normal file
119
tests_v2/test_command_palette_shortcut_hint_sync.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette shortcut hints stay in sync with editable shortcuts.
|
||||
|
||||
Validates:
|
||||
- New Window / Close Window / Rename Tab commands are present in command mode.
|
||||
- Their displayed shortcut hints reflect the current KeyboardShortcutSettings values.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"command palette did not become visible={visible}",
|
||||
)
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit=12) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _open_palette_and_rows(client: cmux, window_id: str, limit: int = 80) -> list:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
_set_palette_visible(client, window_id, True)
|
||||
payload = _palette_results(client, window_id, limit=limit)
|
||||
rows = payload.get("results") or []
|
||||
if not rows:
|
||||
raise cmuxError(f"command palette returned no rows: {payload}")
|
||||
return rows
|
||||
|
||||
|
||||
def _assert_shortcut_hint(rows: list, command_id: str, expected_hint: str) -> None:
|
||||
row = next((row for row in rows if str((row or {}).get("command_id") or "") == command_id), None)
|
||||
if row is None:
|
||||
raise cmuxError(f"missing command palette row for {command_id!r}; rows={rows}")
|
||||
shortcut_hint = str((row or {}).get("shortcut_hint") or "")
|
||||
if shortcut_hint != expected_hint:
|
||||
raise cmuxError(
|
||||
f"unexpected shortcut hint for {command_id}: expected {expected_hint!r}, got {shortcut_hint!r} row={row}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
shortcut_names = ["new_window", "close_window", "rename_tab"]
|
||||
try:
|
||||
rows = _open_palette_and_rows(client, window_id)
|
||||
_assert_shortcut_hint(rows, "palette.newWindow", "⇧⌘N")
|
||||
_assert_shortcut_hint(rows, "palette.closeWindow", "⌃⌘W")
|
||||
_assert_shortcut_hint(rows, "palette.renameTab", "⌘R")
|
||||
|
||||
client.set_shortcut("new_window", "cmd+opt+n")
|
||||
client.set_shortcut("close_window", "cmd+opt+w")
|
||||
client.set_shortcut("rename_tab", "cmd+ctrl+r")
|
||||
|
||||
rows = _open_palette_and_rows(client, window_id)
|
||||
_assert_shortcut_hint(rows, "palette.newWindow", "⌥⌘N")
|
||||
_assert_shortcut_hint(rows, "palette.closeWindow", "⌥⌘W")
|
||||
_assert_shortcut_hint(rows, "palette.renameTab", "⌃⌘R")
|
||||
finally:
|
||||
for name in shortcut_names:
|
||||
try:
|
||||
client.set_shortcut(name, "clear")
|
||||
except cmuxError:
|
||||
pass
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
print("PASS: command-palette shortcut hints track editable shortcuts for new/close/rename window-tab actions")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
128
tests_v2/test_command_palette_switcher_all_windows.py
Normal file
128
tests_v2/test_command_palette_switcher_all_windows.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: cmd+p switcher should include workspaces from every window.
|
||||
|
||||
Why: switcher rows were sourced from the current window's TabManager only, so
|
||||
Cmd+P could not jump to workspaces/tabs owned by other windows.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility in {window_id} did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_a = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_a:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_a)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_b = client.new_window()
|
||||
time.sleep(0.25)
|
||||
|
||||
token_suffix = f"{int(time.time() * 1000)}"
|
||||
token_a = f"cmdp-window-a-{token_suffix}"
|
||||
token_b = f"cmdp-window-b-{token_suffix}"
|
||||
|
||||
workspace_a = client.new_workspace(window_id=window_a)
|
||||
client.rename_workspace(token_a, workspace=workspace_a)
|
||||
|
||||
workspace_b = client.new_workspace(window_id=window_b)
|
||||
client.rename_workspace(token_b, workspace=workspace_b)
|
||||
time.sleep(0.25)
|
||||
|
||||
client.focus_window(window_a)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
_set_palette_visible(client, window_a, False)
|
||||
_set_palette_visible(client, window_b, False)
|
||||
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_a),
|
||||
message="cmd+p did not open palette in window A",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_a).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode in window A",
|
||||
)
|
||||
|
||||
client.simulate_type(token_b)
|
||||
_wait_until(
|
||||
lambda: token_b in str(_palette_results(client, window_a).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update with window B token",
|
||||
)
|
||||
|
||||
result_rows = (_palette_results(client, window_a, limit=64).get("results") or [])
|
||||
target_workspace_command = f"switcher.workspace.{workspace_b.lower()}"
|
||||
if not any(str((row or {}).get("command_id") or "") == target_workspace_command for row in result_rows):
|
||||
raise cmuxError(
|
||||
f"cmd+p switcher in window A did not include workspace from window B "
|
||||
f"(expected {target_workspace_command}); rows={result_rows[:8]}"
|
||||
)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_a),
|
||||
message="palette did not close after selecting cross-window switcher row",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace().lower() == workspace_b.lower(),
|
||||
message="Enter on cross-window switcher row did not move to window B workspace",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: client.current_window().lower() == window_b.lower(),
|
||||
message="Enter on cross-window switcher row did not focus window B",
|
||||
)
|
||||
|
||||
print("PASS: cmd+p switcher includes and navigates to workspaces from other windows")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: cmd+p switcher surface selection across workspaces must focus that surface.
|
||||
|
||||
Why: switching workspaces with an explicit target surface could be overridden by stale
|
||||
per-workspace remembered focus, leaving the destination workspace selected but the wrong
|
||||
surface focused.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_switcher(client: cmux, window_id: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open switcher",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
|
||||
def _rename_surface(client: cmux, surface_id: str, title: str) -> None:
|
||||
client._call(
|
||||
"surface.action",
|
||||
{
|
||||
"surface_id": surface_id,
|
||||
"action": "rename",
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _current_surface_id(client: cmux, workspace_id: str) -> str:
|
||||
payload = client._call("surface.current", {"workspace_id": workspace_id}) or {}
|
||||
return str(payload.get("surface_id") or "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
ws_a = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(ws_a)
|
||||
client.rename_workspace("source-workspace", workspace=ws_a)
|
||||
|
||||
ws_b = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(ws_b)
|
||||
client.rename_workspace("target-workspace", workspace=ws_b)
|
||||
time.sleep(0.2)
|
||||
|
||||
right_surface_id = client.new_split("right")
|
||||
time.sleep(0.2)
|
||||
|
||||
payload = client._call("surface.list", {"workspace_id": ws_b}) or {}
|
||||
rows = payload.get("surfaces") or []
|
||||
if len(rows) < 2:
|
||||
raise cmuxError(f"expected at least two surfaces after split: {payload}")
|
||||
|
||||
left_surface_id = ""
|
||||
for row in rows:
|
||||
sid = str(row.get("id") or "")
|
||||
if sid and sid != right_surface_id:
|
||||
left_surface_id = sid
|
||||
break
|
||||
if not left_surface_id:
|
||||
raise cmuxError(f"failed to resolve left surface id: {payload}")
|
||||
|
||||
token = f"cmdp-crossws-{int(time.time() * 1000)}"
|
||||
_rename_surface(client, right_surface_id, token)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_surface(left_surface_id)
|
||||
_wait_until(
|
||||
lambda: _current_surface_id(client, ws_b).lower() == left_surface_id.lower(),
|
||||
message="failed to prime remembered focus on non-target surface",
|
||||
)
|
||||
|
||||
client.select_workspace(ws_a)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace() == ws_a,
|
||||
message="failed to return to source workspace before cmd+p navigation",
|
||||
)
|
||||
|
||||
_open_switcher(client, window_id)
|
||||
client.simulate_type(token)
|
||||
_wait_until(
|
||||
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update to target token",
|
||||
)
|
||||
|
||||
target_command_id = f"switcher.surface.{ws_b.lower()}.{right_surface_id.lower()}"
|
||||
_wait_until(
|
||||
lambda: str(((_palette_results(client, window_id, limit=24).get("results") or [{}])[0] or {}).get("command_id") or "") == target_command_id,
|
||||
message="target surface row did not become top switcher result",
|
||||
)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="palette did not close after selecting cross-workspace surface row",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace() == ws_b,
|
||||
message="Enter on switcher surface row did not move to target workspace",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: _current_surface_id(client, ws_b).lower() == right_surface_id.lower(),
|
||||
message="Enter on cross-workspace switcher surface row did not focus target surface",
|
||||
)
|
||||
|
||||
client.close_workspace(ws_b)
|
||||
client.close_workspace(ws_a)
|
||||
|
||||
print("PASS: cmd+p switcher focuses selected surface after cross-workspace navigation")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
160
tests_v2/test_command_palette_switcher_renamed_surface.py
Normal file
160
tests_v2/test_command_palette_switcher_renamed_surface.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: cmd+p switcher should search and navigate to renamed surfaces.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_switcher(client: cmux, window_id: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open switcher",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
|
||||
def _rename_surface(client: cmux, surface_id: str, title: str) -> None:
|
||||
client._call(
|
||||
"surface.action",
|
||||
{
|
||||
"surface_id": surface_id,
|
||||
"action": "rename",
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _current_surface_id(client: cmux, workspace_id: str) -> str:
|
||||
payload = client._call("surface.current", {"workspace_id": workspace_id}) or {}
|
||||
return str(payload.get("surface_id") or "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
right_surface_id = client.new_split("right")
|
||||
time.sleep(0.2)
|
||||
|
||||
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
rows = payload.get("surfaces") or []
|
||||
if len(rows) < 2:
|
||||
raise cmuxError(f"expected at least two surfaces after split: {payload}")
|
||||
|
||||
left_surface_id = ""
|
||||
for row in rows:
|
||||
sid = str(row.get("id") or "")
|
||||
if sid and sid != right_surface_id:
|
||||
left_surface_id = sid
|
||||
break
|
||||
if not left_surface_id:
|
||||
raise cmuxError(f"failed to resolve left surface id: {payload}")
|
||||
|
||||
token = f"renamed-surface-{int(time.time() * 1000)}"
|
||||
_rename_surface(client, right_surface_id, token)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_surface(left_surface_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
_open_switcher(client, window_id)
|
||||
client.simulate_type(token)
|
||||
_wait_until(
|
||||
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update to renamed surface token",
|
||||
)
|
||||
|
||||
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
|
||||
if not result_rows:
|
||||
raise cmuxError("switcher returned no rows for renamed surface query")
|
||||
|
||||
top_row = result_rows[0] or {}
|
||||
top_id = str(top_row.get("command_id") or "")
|
||||
top_title = str(top_row.get("title") or "")
|
||||
if not top_id.startswith("switcher.surface."):
|
||||
raise cmuxError(
|
||||
f"expected renamed surface row on top, got top={top_id!r} rows={result_rows}"
|
||||
)
|
||||
if top_title != token:
|
||||
raise cmuxError(
|
||||
f"expected top surface row title to match renamed title {token!r}, got {top_title!r}"
|
||||
)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="palette did not close after selecting renamed surface row",
|
||||
)
|
||||
|
||||
_wait_until(
|
||||
lambda: _current_surface_id(client, workspace_id).lower() == right_surface_id.lower(),
|
||||
message="Enter on renamed surface switcher row did not focus target surface",
|
||||
)
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
|
||||
print("PASS: cmd+p switcher searches and navigates renamed surfaces")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
155
tests_v2/test_command_palette_switcher_surface_precedence.py
Normal file
155
tests_v2/test_command_palette_switcher_surface_precedence.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: switcher should prioritize matching surfaces over workspace rows.
|
||||
|
||||
Why: workspace rows used to index metadata from all surfaces, so a path-token query
|
||||
could rank the workspace row above the actual surface row (because of stable rank
|
||||
tie-breaks), making Enter jump to workspace instead of the intended surface.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_switcher(client: cmux, window_id: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open switcher",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
client.rename_workspace("workspace-no-token", workspace=workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
right_surface_id = client.new_split("right")
|
||||
time.sleep(0.2)
|
||||
|
||||
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
rows = payload.get("surfaces") or []
|
||||
if len(rows) < 2:
|
||||
raise cmuxError(f"expected at least two surfaces after split: {payload}")
|
||||
|
||||
left_surface_id = ""
|
||||
for row in rows:
|
||||
sid = str(row.get("id") or "")
|
||||
if sid and sid != right_surface_id:
|
||||
left_surface_id = sid
|
||||
break
|
||||
if not left_surface_id:
|
||||
raise cmuxError(f"failed to resolve left surface id: {payload}")
|
||||
|
||||
token = f"cmdp-switcher-target-{int(time.time() * 1000)}"
|
||||
target_dir = f"/tmp/{token}"
|
||||
|
||||
client.send_surface(left_surface_id, "cd /tmp\n")
|
||||
client.send_surface(
|
||||
right_surface_id,
|
||||
f"mkdir -p {target_dir} && cd {target_dir}\n",
|
||||
)
|
||||
client.focus_surface(left_surface_id)
|
||||
time.sleep(0.8)
|
||||
|
||||
_open_switcher(client, window_id)
|
||||
client.simulate_type(token)
|
||||
_wait_until(
|
||||
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update to target token",
|
||||
)
|
||||
|
||||
def _has_surface_match() -> bool:
|
||||
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
|
||||
return any(str((row or {}).get("command_id") or "").startswith("switcher.surface.") for row in result_rows)
|
||||
|
||||
_wait_until(
|
||||
_has_surface_match,
|
||||
timeout_s=8.0,
|
||||
message="switcher results never produced a matching surface row for token query",
|
||||
)
|
||||
|
||||
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
|
||||
if not result_rows:
|
||||
raise cmuxError("switcher returned no rows for token query")
|
||||
|
||||
top_id = str((result_rows[0] or {}).get("command_id") or "")
|
||||
if not top_id.startswith("switcher.surface."):
|
||||
raise cmuxError(f"expected a surface row on top for token query, got top={top_id!r} rows={result_rows}")
|
||||
|
||||
workspace_matches = [
|
||||
str((row or {}).get("command_id") or "")
|
||||
for row in result_rows
|
||||
if str((row or {}).get("command_id") or "").startswith("switcher.workspace.")
|
||||
]
|
||||
if workspace_matches:
|
||||
raise cmuxError(
|
||||
f"workspace row should not match a non-focused surface path token; workspace matches={workspace_matches} rows={result_rows}"
|
||||
)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.close_workspace(workspace_id)
|
||||
|
||||
print("PASS: switcher ranks matching surface rows ahead of workspace rows for path-token queries")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
127
tests_v2/test_command_palette_switcher_type_labels.py
Normal file
127
tests_v2/test_command_palette_switcher_type_labels.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: cmd+p switcher rows expose right-side type labels.
|
||||
|
||||
Expected trailing labels:
|
||||
- switcher.workspace.* => Workspace
|
||||
- switcher.surface.* => Surface
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_switcher(client: cmux, window_id: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open switcher",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
token = f"switchertype{int(time.time() * 1000)}"
|
||||
client.rename_workspace(token, workspace=workspace_id)
|
||||
_ = client.new_split("right")
|
||||
time.sleep(0.3)
|
||||
|
||||
_open_switcher(client, window_id)
|
||||
client.simulate_type(token)
|
||||
_wait_until(
|
||||
lambda: token in str(_palette_results(client, window_id, limit=60).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update to workspace token",
|
||||
)
|
||||
|
||||
rows = (_palette_results(client, window_id, limit=60).get("results") or [])
|
||||
if not rows:
|
||||
raise cmuxError("switcher returned no rows for token query")
|
||||
|
||||
workspace_rows = [
|
||||
row for row in rows
|
||||
if str((row or {}).get("command_id") or "").startswith("switcher.workspace.")
|
||||
]
|
||||
surface_rows = [
|
||||
row for row in rows
|
||||
if str((row or {}).get("command_id") or "").startswith("switcher.surface.")
|
||||
]
|
||||
|
||||
if not workspace_rows:
|
||||
raise cmuxError(f"expected workspace rows for switcher query: rows={rows}")
|
||||
if not surface_rows:
|
||||
raise cmuxError(f"expected surface rows for switcher query: rows={rows}")
|
||||
|
||||
bad_workspace = [row for row in workspace_rows if str((row or {}).get("trailing_label") or "") != "Workspace"]
|
||||
if bad_workspace:
|
||||
raise cmuxError(f"workspace rows missing 'Workspace' trailing label: {bad_workspace}")
|
||||
|
||||
bad_surface = [row for row in surface_rows if str((row or {}).get("trailing_label") or "") != "Surface"]
|
||||
if bad_surface:
|
||||
raise cmuxError(f"surface rows missing 'Surface' trailing label: {bad_surface}")
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.close_workspace(workspace_id)
|
||||
|
||||
print("PASS: cmd+p switcher rows report Workspace/Surface trailing labels")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
219
tests_v2/test_command_palette_window_scope.py
Normal file
219
tests_v2/test_command_palette_window_scope.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command palette should open only in the active window.
|
||||
|
||||
Why: if command-palette toggle is broadcast to all windows, inactive windows can
|
||||
end up with an open palette that steals focus once they become key.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(res.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
timeout_s=3.0,
|
||||
message=f"palette in {window_id} did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _focus_window(client: cmux, window_id: str) -> None:
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
_wait_until(
|
||||
lambda: client.current_window().lower() == window_id.lower(),
|
||||
timeout_s=3.0,
|
||||
message=f"failed to focus window {window_id}",
|
||||
)
|
||||
time.sleep(0.15)
|
||||
|
||||
|
||||
def _assert_shortcut_window_scoped(client: cmux, shortcut: str, w1: str, w2: str) -> None:
|
||||
_set_palette_visible(client, w1, False)
|
||||
_set_palette_visible(client, w2, False)
|
||||
|
||||
_focus_window(client, w1)
|
||||
client.simulate_shortcut(shortcut)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message=f"{shortcut} did not open palette in window1",
|
||||
)
|
||||
if _palette_visible(client, w2):
|
||||
raise cmuxError(f"{shortcut} in window1 incorrectly opened palette in window2")
|
||||
|
||||
_focus_window(client, w2)
|
||||
client.simulate_shortcut(shortcut)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message=f"{shortcut} did not open palette in window2",
|
||||
)
|
||||
if not _palette_visible(client, w1):
|
||||
raise cmuxError(
|
||||
f"{shortcut} in window2 incorrectly toggled window1 palette off "
|
||||
"(cross-window routing regression)"
|
||||
)
|
||||
|
||||
client.simulate_shortcut(shortcut)
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message=f"second {shortcut} did not close palette in window2",
|
||||
)
|
||||
if not _palette_visible(client, w1):
|
||||
raise cmuxError(
|
||||
f"second {shortcut} in window2 incorrectly changed window1 palette visibility"
|
||||
)
|
||||
|
||||
_focus_window(client, w1)
|
||||
client.simulate_shortcut(shortcut)
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message=f"second {shortcut} did not close palette in window1",
|
||||
)
|
||||
|
||||
|
||||
def _assert_cross_window_typing_after_mixed_shortcuts(client: cmux, w1: str, w2: str) -> None:
|
||||
_set_palette_visible(client, w1, False)
|
||||
_set_palette_visible(client, w2, False)
|
||||
|
||||
_focus_window(client, w1)
|
||||
client.simulate_shortcut("cmd+shift+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message="cmd+shift+p did not open palette in window1",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, w1).get("mode") or "") == "commands",
|
||||
timeout_s=3.0,
|
||||
message="window1 palette did not enter commands mode",
|
||||
)
|
||||
window1_query_before = str(_palette_results(client, w1).get("query") or "")
|
||||
|
||||
_focus_window(client, w2)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message="cmd+p did not open palette in window2",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, w2).get("mode") or "") == "switcher",
|
||||
timeout_s=3.0,
|
||||
message="window2 palette did not enter switcher mode",
|
||||
)
|
||||
|
||||
typed = ""
|
||||
for ch in "crosswindow":
|
||||
typed += ch
|
||||
client.simulate_type(ch)
|
||||
_wait_until(
|
||||
lambda expected=typed: str(_palette_results(client, w2).get("query") or "").lower() == expected,
|
||||
timeout_s=1.8,
|
||||
message=(
|
||||
"typing into window2 palette did not accumulate query text "
|
||||
f"(expected {typed!r})"
|
||||
),
|
||||
)
|
||||
|
||||
window1_query_now = str(_palette_results(client, w1).get("query") or "")
|
||||
if window1_query_now != window1_query_before:
|
||||
raise cmuxError(
|
||||
"typing in window2 changed window1 command-palette query "
|
||||
f"(before={window1_query_before!r}, now={window1_query_now!r})"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
w1 = client.current_window()
|
||||
w2 = client.new_window()
|
||||
time.sleep(0.25)
|
||||
|
||||
_ = client.new_workspace(window_id=w1)
|
||||
_ = client.new_workspace(window_id=w2)
|
||||
time.sleep(0.25)
|
||||
_set_palette_visible(client, w1, False)
|
||||
_set_palette_visible(client, w2, False)
|
||||
|
||||
# Open palette in window1 and verify window2 remains untouched.
|
||||
client._call("debug.command_palette.toggle", {"window_id": w1})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message="window1 command palette did not open",
|
||||
)
|
||||
if _palette_visible(client, w2):
|
||||
raise cmuxError("window2 palette became visible when toggling window1")
|
||||
|
||||
# Closing window1 palette should not affect window2.
|
||||
client._call("debug.command_palette.toggle", {"window_id": w1})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message="window1 command palette did not close",
|
||||
)
|
||||
|
||||
# Mirror the same check in the other direction.
|
||||
client._call("debug.command_palette.toggle", {"window_id": w2})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message="window2 command palette did not open",
|
||||
)
|
||||
if _palette_visible(client, w1):
|
||||
raise cmuxError("window1 palette became visible when toggling window2")
|
||||
client._call("debug.command_palette.toggle", {"window_id": w2})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message="window2 command palette did not close",
|
||||
)
|
||||
|
||||
# Reproduce keyboard-shortcut window-scoping path:
|
||||
# opening from window2 must not jump back and toggle window1.
|
||||
_assert_shortcut_window_scoped(client, "cmd+shift+p", w1, w2)
|
||||
_assert_shortcut_window_scoped(client, "cmd+p", w1, w2)
|
||||
_assert_cross_window_typing_after_mixed_shortcuts(client, w1, w2)
|
||||
|
||||
print("PASS: command palette is scoped to active window")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -9,6 +9,7 @@ This test checks for:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s
|
|||
return violations
|
||||
|
||||
|
||||
def check_command_palette_caret_tint(repo_root: Path) -> List[str]:
|
||||
"""Ensure command palette text inputs keep a white caret tint."""
|
||||
content_view = repo_root / "Sources" / "ContentView.swift"
|
||||
if not content_view.exists():
|
||||
return [f"Missing expected file: {content_view}"]
|
||||
|
||||
try:
|
||||
content = content_view.read_text()
|
||||
except Exception as e:
|
||||
return [f"Could not read {content_view}: {e}"]
|
||||
|
||||
checks = [
|
||||
(
|
||||
"search input",
|
||||
r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteSearchFocused\)",
|
||||
),
|
||||
(
|
||||
"rename input",
|
||||
r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteRenameFocused\)",
|
||||
),
|
||||
]
|
||||
|
||||
violations: List[str] = []
|
||||
for label, pattern in checks:
|
||||
match = re.search(pattern, content, flags=re.DOTALL)
|
||||
if not match:
|
||||
violations.append(
|
||||
f"Could not locate command palette {label} TextField block in Sources/ContentView.swift"
|
||||
)
|
||||
continue
|
||||
|
||||
body = match.group("body")
|
||||
if ".tint(.white)" not in body:
|
||||
violations.append(
|
||||
f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift"
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the lint checks."""
|
||||
repo_root = get_repo_root()
|
||||
|
|
@ -102,15 +145,18 @@ def main():
|
|||
print(f"Checking {len(swift_files)} Swift files for performance issues...")
|
||||
|
||||
# Check for auto-updating Text styles
|
||||
violations = check_autoupdating_text_styles(swift_files)
|
||||
style_violations = check_autoupdating_text_styles(swift_files)
|
||||
tint_violations = check_command_palette_caret_tint(repo_root)
|
||||
has_failures = False
|
||||
|
||||
if violations:
|
||||
if style_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Auto-updating Text styles found")
|
||||
print("=" * 60)
|
||||
print("These patterns cause continuous SwiftUI view updates and high CPU usage:")
|
||||
print()
|
||||
|
||||
for file_path, line_num, line in violations:
|
||||
for file_path, line_num, line in style_violations:
|
||||
rel_path = file_path.relative_to(repo_root)
|
||||
print(f" {rel_path}:{line_num}")
|
||||
print(f" {line}")
|
||||
|
|
@ -120,9 +166,23 @@ def main():
|
|||
print(" Instead of: Text(date, style: .time)")
|
||||
print(" Use: Text(date.formatted(date: .omitted, time: .shortened))")
|
||||
print()
|
||||
|
||||
if tint_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Command palette caret tint drifted")
|
||||
print("=" * 60)
|
||||
print("The command palette search and rename text fields must keep a white caret:")
|
||||
print()
|
||||
for message in tint_violations:
|
||||
print(f" {message}")
|
||||
print()
|
||||
print("FIX: Set command palette TextField tint modifiers to `.white`.")
|
||||
print()
|
||||
|
||||
if has_failures:
|
||||
return 1
|
||||
|
||||
print("✅ No auto-updating Text style patterns found")
|
||||
print("✅ No linted SwiftUI pattern regressions found")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
107
tests_v2/test_shortcut_window_scope.py
Normal file
107
tests_v2/test_shortcut_window_scope.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: app shortcuts must apply to the focused window only.
|
||||
|
||||
Covers:
|
||||
- Cmd+B (toggle sidebar) should only affect the active window.
|
||||
- Cmd+T (new terminal tab/surface) should only affect the active window.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 4.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _sidebar_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.sidebar.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _surface_count(client: cmux, workspace_id: str) -> int:
|
||||
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
return len(payload.get("surfaces") or [])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_a = client.current_window()
|
||||
window_b = client.new_window()
|
||||
time.sleep(0.25)
|
||||
|
||||
workspace_a = client.new_workspace(window_id=window_a)
|
||||
workspace_b = client.new_workspace(window_id=window_b)
|
||||
time.sleep(0.25)
|
||||
|
||||
client.focus_window(window_a)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
a_before = _sidebar_visible(client, window_a)
|
||||
b_before = _sidebar_visible(client, window_b)
|
||||
|
||||
client.simulate_shortcut("cmd+b")
|
||||
_wait_until(
|
||||
lambda: _sidebar_visible(client, window_a) != a_before,
|
||||
message="Cmd+B did not toggle sidebar in active window A",
|
||||
)
|
||||
a_after = _sidebar_visible(client, window_a)
|
||||
b_after = _sidebar_visible(client, window_b)
|
||||
if b_after != b_before:
|
||||
raise cmuxError("Cmd+B in window A incorrectly toggled sidebar in window B")
|
||||
|
||||
client.focus_window(window_b)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
client.simulate_shortcut("cmd+b")
|
||||
_wait_until(
|
||||
lambda: _sidebar_visible(client, window_b) != b_after,
|
||||
message="Cmd+B did not toggle sidebar in active window B",
|
||||
)
|
||||
if _sidebar_visible(client, window_a) != a_after:
|
||||
raise cmuxError("Cmd+B in window B incorrectly toggled sidebar in window A")
|
||||
|
||||
client.focus_window(window_a)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
client.select_workspace(workspace_a)
|
||||
time.sleep(0.1)
|
||||
|
||||
count_a_before = _surface_count(client, workspace_a)
|
||||
count_b_before = _surface_count(client, workspace_b)
|
||||
|
||||
client.simulate_shortcut("cmd+t")
|
||||
_wait_until(
|
||||
lambda: _surface_count(client, workspace_a) == count_a_before + 1,
|
||||
message="Cmd+T did not create a new surface in active window A",
|
||||
)
|
||||
|
||||
count_b_after = _surface_count(client, workspace_b)
|
||||
if count_b_after != count_b_before:
|
||||
raise cmuxError("Cmd+T in window A incorrectly created a surface in window B")
|
||||
|
||||
print("PASS: window-scoped shortcuts stay in the active window (Cmd+B, Cmd+T)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
211
tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py
Normal file
211
tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fuzz regression: rapid Cmd+D / Ctrl+D churn must not shift the outer bonsplit container frame.
|
||||
|
||||
This targets the user-reported visual shift/flash while spamming split + close.
|
||||
We treat any drift in x/y/width/height of the outer container frame as a failure.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_FUZZ_SEED", "424242"))
|
||||
FUZZ_STEPS = int(os.environ.get("CMUX_SPLIT_FUZZ_STEPS", "1400"))
|
||||
SAMPLES_PER_STEP = int(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLES", "4"))
|
||||
SAMPLE_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLE_INTERVAL_S", "0.0015"))
|
||||
ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_FUZZ_ACTION_JITTER_MAX_S", "0.0035"))
|
||||
BURST_MAX = int(os.environ.get("CMUX_SPLIT_FUZZ_BURST_MAX", "3"))
|
||||
MAX_PANES = int(os.environ.get("CMUX_SPLIT_FUZZ_MAX_PANES", "10"))
|
||||
EPSILON = float(os.environ.get("CMUX_SPLIT_FUZZ_EPSILON", "0.0"))
|
||||
TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_FUZZ_TRACE_TAIL", "40"))
|
||||
ASSERT_NO_UNDERFLOW = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_UNDERFLOW", "0") == "1"
|
||||
ASSERT_NO_EMPTY_PANEL = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_EMPTY_PANEL", "0") == "1"
|
||||
|
||||
|
||||
def _pane_count(layout_payload: dict) -> int:
|
||||
layout = layout_payload.get("layout") or {}
|
||||
panes = layout.get("panes") or []
|
||||
return len(panes)
|
||||
|
||||
|
||||
def _largest_split_frame(layout_payload: dict) -> dict:
|
||||
selected = layout_payload.get("selectedPanels") or []
|
||||
best = None
|
||||
best_area = -1.0
|
||||
|
||||
for row in selected:
|
||||
for split in row.get("splitViews") or []:
|
||||
frame = split.get("frame")
|
||||
if not frame:
|
||||
continue
|
||||
|
||||
try:
|
||||
x = float(frame.get("x", 0.0))
|
||||
y = float(frame.get("y", 0.0))
|
||||
width = float(frame.get("width", 0.0))
|
||||
height = float(frame.get("height", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if width <= 0.0 or height <= 0.0:
|
||||
continue
|
||||
|
||||
area = width * height
|
||||
if area > best_area:
|
||||
best_area = area
|
||||
best = {"x": x, "y": y, "width": width, "height": height}
|
||||
|
||||
if best is None:
|
||||
raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}")
|
||||
return best
|
||||
|
||||
|
||||
def _container_frame(layout_payload: dict) -> dict:
|
||||
container = (layout_payload.get("layout") or {}).get("containerFrame")
|
||||
if container:
|
||||
try:
|
||||
return {
|
||||
"x": float(container.get("x", 0.0)),
|
||||
"y": float(container.get("y", 0.0)),
|
||||
"width": float(container.get("width", 0.0)),
|
||||
"height": float(container.get("height", 0.0)),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Back-compat fallback for older payloads that don't expose containerFrame.
|
||||
return _largest_split_frame(layout_payload)
|
||||
|
||||
|
||||
def _assert_same_frame(
|
||||
current: dict,
|
||||
baseline: dict,
|
||||
*,
|
||||
step: int,
|
||||
sample: int,
|
||||
action: str,
|
||||
seed: int,
|
||||
action_index: int,
|
||||
trace: list[str],
|
||||
) -> None:
|
||||
deltas = {
|
||||
key: abs(float(current[key]) - float(baseline[key]))
|
||||
for key in ("x", "y", "width", "height")
|
||||
}
|
||||
shifted = {k: v for k, v in deltas.items() if v > EPSILON}
|
||||
if shifted:
|
||||
raise cmuxError(
|
||||
"Outer split container shifted during fuzz churn "
|
||||
f"(step={step}, sample={sample}, action={action}, action_index={action_index}, seed={seed}, "
|
||||
f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON})"
|
||||
f"; recent_actions={trace}"
|
||||
)
|
||||
|
||||
|
||||
def _warm_start_split(c: cmux) -> dict:
|
||||
# Ensure we have at least one split so the container frame exists in layout_debug.
|
||||
c.simulate_shortcut("cmd+d")
|
||||
deadline = time.time() + 2.0
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
payload = c.layout_debug()
|
||||
last = payload
|
||||
if _pane_count(payload) >= 2:
|
||||
return payload
|
||||
time.sleep(0.02)
|
||||
raise cmuxError(f"Timed out waiting for first split to appear: {last}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rng = random.Random(FUZZ_SEED)
|
||||
recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL))
|
||||
total_actions = 0
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
ws = c.new_workspace()
|
||||
c.select_workspace(ws)
|
||||
c.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
c.reset_bonsplit_underflow_count()
|
||||
c.reset_empty_panel_count()
|
||||
|
||||
initial = _warm_start_split(c)
|
||||
baseline = _container_frame(initial)
|
||||
if _pane_count(initial) < 2:
|
||||
raise cmuxError("Expected at least 2 panes after warm start split")
|
||||
|
||||
for step in range(1, FUZZ_STEPS + 1):
|
||||
burst = rng.randint(1, max(1, BURST_MAX))
|
||||
|
||||
for burst_index in range(1, burst + 1):
|
||||
before = c.layout_debug()
|
||||
pane_count = _pane_count(before)
|
||||
|
||||
if pane_count <= 2:
|
||||
action = "cmd+d"
|
||||
elif pane_count >= MAX_PANES:
|
||||
action = "ctrl+d"
|
||||
else:
|
||||
# Bias toward split to keep churn dense while still frequently collapsing via ctrl+d.
|
||||
action = "cmd+d" if rng.random() < 0.60 else "ctrl+d"
|
||||
|
||||
if action == "cmd+d":
|
||||
c.simulate_shortcut("cmd+d")
|
||||
else:
|
||||
# Ctrl+D equivalent sent directly to the focused terminal surface.
|
||||
c.send_ctrl_d()
|
||||
|
||||
total_actions += 1
|
||||
recent_actions.append(
|
||||
f"step={step}/burst={burst_index}/{burst} panes_before={pane_count} action={action}"
|
||||
)
|
||||
|
||||
# Random micro-jitter to emulate uneven key-repeat timing while keeping churn fast.
|
||||
if ACTION_JITTER_MAX_S > 0:
|
||||
time.sleep(rng.uniform(0.0, ACTION_JITTER_MAX_S))
|
||||
|
||||
# Sample repeatedly after each burst to catch transient shifts.
|
||||
for sample in range(0, SAMPLES_PER_STEP + 1):
|
||||
payload = c.layout_debug()
|
||||
current = _container_frame(payload)
|
||||
_assert_same_frame(
|
||||
current,
|
||||
baseline,
|
||||
step=step,
|
||||
sample=sample,
|
||||
action="burst",
|
||||
seed=FUZZ_SEED,
|
||||
action_index=total_actions,
|
||||
trace=list(recent_actions),
|
||||
)
|
||||
if SAMPLE_INTERVAL_S > 0:
|
||||
time.sleep(rng.uniform(0.0, SAMPLE_INTERVAL_S))
|
||||
|
||||
underflows = c.bonsplit_underflow_count()
|
||||
if ASSERT_NO_UNDERFLOW and underflows != 0:
|
||||
raise cmuxError(f"bonsplit arranged-subview underflow observed during fuzz run: {underflows}")
|
||||
|
||||
flashes = c.empty_panel_count()
|
||||
if ASSERT_NO_EMPTY_PANEL and flashes != 0:
|
||||
raise cmuxError(f"EmptyPanelView appeared during fuzz run (count={flashes})")
|
||||
|
||||
print(
|
||||
"PASS: cmd+d/ctrl+d fuzz geometry invariant "
|
||||
f"(seed={FUZZ_SEED}, steps={FUZZ_STEPS}, samples={SAMPLES_PER_STEP}, burst_max={BURST_MAX}, "
|
||||
f"actions={total_actions}, epsilon={EPSILON}, underflows={underflows}, empty_panel={flashes})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
487
tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py
Normal file
487
tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Focused fuzz regression for rapid Cmd+D / Ctrl+D churn in a strict 1<->2 pane loop.
|
||||
|
||||
Intent:
|
||||
- Keep topology limited to one pane or two left/right panes only.
|
||||
- Run across multiple fresh workspaces.
|
||||
- Sample layout as fast as the debug socket allows during transitions/holds.
|
||||
- Fail immediately if outer container x/y/width/height drifts at any sampled frame.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_2PANE_SEED", "20260223"))
|
||||
WORKSPACES = int(os.environ.get("CMUX_SPLIT_2PANE_WORKSPACES", "3"))
|
||||
CYCLES_PER_WORKSPACE = int(os.environ.get("CMUX_SPLIT_2PANE_CYCLES", "220"))
|
||||
TRANSITION_TIMEOUT_S = float(os.environ.get("CMUX_SPLIT_2PANE_TIMEOUT_S", "2.0"))
|
||||
HOLD_MIN_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MIN_S", "0.003"))
|
||||
HOLD_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MAX_S", "0.018"))
|
||||
PRE_ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_ACTION_JITTER_MAX_S", "0.002"))
|
||||
EPSILON = float(os.environ.get("CMUX_SPLIT_2PANE_EPSILON", "0.0"))
|
||||
TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_2PANE_TRACE_TAIL", "64"))
|
||||
LAYOUT_POLL_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_POLL_SLEEP_S", "0.0008"))
|
||||
LAYOUT_TIMEOUT_RETRIES = int(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRIES", "4"))
|
||||
LAYOUT_TIMEOUT_RETRY_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRY_SLEEP_S", "0.0015"))
|
||||
MAX_LAYOUT_TIMEOUTS = int(os.environ.get("CMUX_SPLIT_2PANE_MAX_LAYOUT_TIMEOUTS", "80"))
|
||||
CTRL_D_RETRY_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_RETRY_INTERVAL_S", "0.18"))
|
||||
CTRL_D_MAX_EXTRA = int(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_MAX_EXTRA", "6"))
|
||||
|
||||
|
||||
def _pane_count(layout_payload: dict) -> int:
|
||||
layout = layout_payload.get("layout") or {}
|
||||
return len(layout.get("panes") or [])
|
||||
|
||||
|
||||
def _largest_split_frame(layout_payload: dict) -> dict:
|
||||
selected = layout_payload.get("selectedPanels") or []
|
||||
best = None
|
||||
best_area = -1.0
|
||||
for row in selected:
|
||||
for split in row.get("splitViews") or []:
|
||||
frame = split.get("frame")
|
||||
if not frame:
|
||||
continue
|
||||
try:
|
||||
x = float(frame.get("x", 0.0))
|
||||
y = float(frame.get("y", 0.0))
|
||||
width = float(frame.get("width", 0.0))
|
||||
height = float(frame.get("height", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if width <= 0.0 or height <= 0.0:
|
||||
continue
|
||||
area = width * height
|
||||
if area > best_area:
|
||||
best_area = area
|
||||
best = {"x": x, "y": y, "width": width, "height": height}
|
||||
if best is None:
|
||||
raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}")
|
||||
return best
|
||||
|
||||
|
||||
def _container_frame(layout_payload: dict) -> dict:
|
||||
container = (layout_payload.get("layout") or {}).get("containerFrame")
|
||||
if container:
|
||||
try:
|
||||
return {
|
||||
"x": float(container.get("x", 0.0)),
|
||||
"y": float(container.get("y", 0.0)),
|
||||
"width": float(container.get("width", 0.0)),
|
||||
"height": float(container.get("height", 0.0)),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return _largest_split_frame(layout_payload)
|
||||
|
||||
|
||||
def _pane_frames_sorted_x(layout_payload: dict) -> list[dict]:
|
||||
layout = layout_payload.get("layout") or {}
|
||||
panes = layout.get("panes") or []
|
||||
frames: list[dict] = []
|
||||
for pane in panes:
|
||||
frame = pane.get("frame") or {}
|
||||
try:
|
||||
frames.append(
|
||||
{
|
||||
"pane_id": str(pane.get("paneId") or ""),
|
||||
"x": float(frame.get("x", 0.0)),
|
||||
"y": float(frame.get("y", 0.0)),
|
||||
"width": float(frame.get("width", 0.0)),
|
||||
"height": float(frame.get("height", 0.0)),
|
||||
}
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return sorted(frames, key=lambda p: (p["x"], p["y"]))
|
||||
|
||||
|
||||
def _assert_same_frame(
|
||||
*,
|
||||
current: dict,
|
||||
baseline: dict,
|
||||
workspace_index: int,
|
||||
cycle: int,
|
||||
phase: str,
|
||||
sample: int,
|
||||
trace: list[str],
|
||||
) -> None:
|
||||
deltas = {
|
||||
key: abs(float(current[key]) - float(baseline[key]))
|
||||
for key in ("x", "y", "width", "height")
|
||||
}
|
||||
shifted = {k: v for k, v in deltas.items() if v > EPSILON}
|
||||
if shifted:
|
||||
raise cmuxError(
|
||||
"Container frame shifted "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sample={sample}, "
|
||||
f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON}); "
|
||||
f"recent_actions={trace}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_two_panes_left_right(layout_payload: dict, *, workspace_index: int, cycle: int, trace: list[str]) -> None:
|
||||
panes = _pane_frames_sorted_x(layout_payload)
|
||||
if len(panes) != 2:
|
||||
raise cmuxError(
|
||||
f"Expected exactly 2 panes in two-pane phase, got {len(panes)} "
|
||||
f"(workspace={workspace_index}, cycle={cycle}); panes={panes}; recent_actions={trace}"
|
||||
)
|
||||
|
||||
left, right = panes[0], panes[1]
|
||||
if left["width"] <= 0.0 or left["height"] <= 0.0 or right["width"] <= 0.0 or right["height"] <= 0.0:
|
||||
raise cmuxError(
|
||||
f"Collapsed pane in two-pane phase (workspace={workspace_index}, cycle={cycle}): "
|
||||
f"left={left} right={right}; recent_actions={trace}"
|
||||
)
|
||||
|
||||
if left["x"] >= right["x"]:
|
||||
raise cmuxError(
|
||||
f"Two-pane geometry is not left/right (workspace={workspace_index}, cycle={cycle}): "
|
||||
f"left={left} right={right}; recent_actions={trace}"
|
||||
)
|
||||
|
||||
|
||||
def _selected_panel_by_pane(layout_payload: dict) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for row in layout_payload.get("selectedPanels") or []:
|
||||
pane_id = str(row.get("paneId") or "")
|
||||
panel_id = str(row.get("panelId") or "")
|
||||
if pane_id and panel_id:
|
||||
out[pane_id] = panel_id
|
||||
return out
|
||||
|
||||
|
||||
def _rightmost_pane_id(layout_payload: dict) -> str:
|
||||
panes = _pane_frames_sorted_x(layout_payload)
|
||||
if len(panes) < 2:
|
||||
raise cmuxError(f"Expected at least 2 panes to resolve rightmost pane: {panes}")
|
||||
pane_id = str(panes[-1].get("pane_id") or "")
|
||||
if not pane_id:
|
||||
raise cmuxError(f"Rightmost pane is missing pane_id: {panes[-1]}")
|
||||
return pane_id
|
||||
|
||||
|
||||
def _rightmost_panel_id(layout_payload: dict) -> str:
|
||||
pane_id = _rightmost_pane_id(layout_payload)
|
||||
selected = _selected_panel_by_pane(layout_payload)
|
||||
panel_id = str(selected.get(pane_id) or "")
|
||||
if not panel_id:
|
||||
raise cmuxError(f"Missing selected panel for rightmost pane: pane_id={pane_id}, selected={selected}")
|
||||
return panel_id
|
||||
|
||||
|
||||
def _safe_layout_debug(c: cmux, *, timeout_state: dict[str, int], context: str) -> dict:
|
||||
for attempt in range(0, max(0, LAYOUT_TIMEOUT_RETRIES) + 1):
|
||||
try:
|
||||
return c.layout_debug()
|
||||
except cmuxError as exc:
|
||||
if "timed out waiting for response" not in str(exc).lower():
|
||||
raise
|
||||
|
||||
timeout_state["count"] = timeout_state.get("count", 0) + 1
|
||||
count = timeout_state["count"]
|
||||
if count > max(0, MAX_LAYOUT_TIMEOUTS):
|
||||
raise cmuxError(
|
||||
f"Exceeded layout_debug timeout budget (count={count}, max={MAX_LAYOUT_TIMEOUTS}, context={context})"
|
||||
) from exc
|
||||
|
||||
if attempt >= max(0, LAYOUT_TIMEOUT_RETRIES):
|
||||
raise cmuxError(
|
||||
f"layout_debug timed out after retries (attempts={attempt + 1}, count={count}, context={context})"
|
||||
) from exc
|
||||
|
||||
if LAYOUT_TIMEOUT_RETRY_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_TIMEOUT_RETRY_SLEEP_S)
|
||||
|
||||
raise cmuxError(f"layout_debug retry loop exhausted unexpectedly (context={context})")
|
||||
|
||||
|
||||
def _sample_while(
|
||||
c: cmux,
|
||||
*,
|
||||
baseline: dict,
|
||||
deadline: float,
|
||||
workspace_index: int,
|
||||
cycle: int,
|
||||
phase: str,
|
||||
trace: list[str],
|
||||
timeout_state: dict[str, int],
|
||||
) -> int:
|
||||
sampled = 0
|
||||
while time.time() < deadline:
|
||||
payload = _safe_layout_debug(
|
||||
c,
|
||||
timeout_state=timeout_state,
|
||||
context=f"sample workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
|
||||
)
|
||||
current = _container_frame(payload)
|
||||
_assert_same_frame(
|
||||
current=current,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase=phase,
|
||||
sample=sampled,
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
panes_now = _pane_count(payload)
|
||||
if panes_now > 2:
|
||||
raise cmuxError(
|
||||
f"Observed >2 panes in strict two-pane fuzz "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
|
||||
f"recent_actions={trace}"
|
||||
)
|
||||
sampled += 1
|
||||
if LAYOUT_POLL_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_POLL_SLEEP_S)
|
||||
return sampled
|
||||
|
||||
|
||||
def _wait_for_panes(
|
||||
c: cmux,
|
||||
*,
|
||||
target_panes: int,
|
||||
baseline: dict,
|
||||
workspace_index: int,
|
||||
cycle: int,
|
||||
phase: str,
|
||||
timeout_s: float,
|
||||
trace: list[str],
|
||||
timeout_state: dict[str, int],
|
||||
) -> tuple[dict, int]:
|
||||
deadline = time.time() + timeout_s
|
||||
sampled = 0
|
||||
last = None
|
||||
|
||||
while time.time() < deadline:
|
||||
payload = _safe_layout_debug(
|
||||
c,
|
||||
timeout_state=timeout_state,
|
||||
context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
|
||||
)
|
||||
last = payload
|
||||
current = _container_frame(payload)
|
||||
_assert_same_frame(
|
||||
current=current,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase=phase,
|
||||
sample=sampled,
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
panes_now = _pane_count(payload)
|
||||
if panes_now > 2:
|
||||
raise cmuxError(
|
||||
f"Observed >2 panes in strict two-pane fuzz while waiting "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
|
||||
f"recent_actions={trace}"
|
||||
)
|
||||
if panes_now == target_panes:
|
||||
return payload, sampled + 1
|
||||
sampled += 1
|
||||
if LAYOUT_POLL_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_POLL_SLEEP_S)
|
||||
|
||||
raise cmuxError(
|
||||
f"Timed out waiting for {target_panes} panes "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, "
|
||||
f"last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); recent_actions={trace}"
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_single_pane_after_ctrl_d(
|
||||
c: cmux,
|
||||
*,
|
||||
baseline: dict,
|
||||
workspace_index: int,
|
||||
cycle: int,
|
||||
phase: str,
|
||||
timeout_s: float,
|
||||
recent_actions: deque[str],
|
||||
timeout_state: dict[str, int],
|
||||
) -> tuple[dict, int, int]:
|
||||
deadline = time.time() + timeout_s
|
||||
sampled = 0
|
||||
extra_ctrl_d = 0
|
||||
last = None
|
||||
next_retry_at = time.time() + max(0.0, CTRL_D_RETRY_INTERVAL_S)
|
||||
|
||||
while time.time() < deadline:
|
||||
payload = _safe_layout_debug(
|
||||
c,
|
||||
timeout_state=timeout_state,
|
||||
context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
|
||||
)
|
||||
last = payload
|
||||
current = _container_frame(payload)
|
||||
trace = list(recent_actions)
|
||||
_assert_same_frame(
|
||||
current=current,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase=phase,
|
||||
sample=sampled,
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
panes_now = _pane_count(payload)
|
||||
if panes_now > 2:
|
||||
raise cmuxError(
|
||||
f"Observed >2 panes in strict two-pane fuzz while waiting "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
|
||||
f"recent_actions={trace}"
|
||||
)
|
||||
if panes_now == 1:
|
||||
return payload, sampled + 1, extra_ctrl_d
|
||||
|
||||
now = time.time()
|
||||
if panes_now == 2 and extra_ctrl_d < max(0, CTRL_D_MAX_EXTRA) and now >= next_retry_at:
|
||||
retry_right_panel_id = _rightmost_panel_id(payload)
|
||||
try:
|
||||
c.send_key_surface(retry_right_panel_id, "ctrl-d")
|
||||
except cmuxError as exc:
|
||||
# Pane/surface can disappear between layout sample and send call under heavy churn.
|
||||
# Skip this retry tick and re-sample.
|
||||
if "not_found" in str(exc).lower():
|
||||
next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S)
|
||||
sampled += 1
|
||||
if LAYOUT_POLL_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_POLL_SLEEP_S)
|
||||
continue
|
||||
raise
|
||||
extra_ctrl_d += 1
|
||||
recent_actions.append(
|
||||
f"ws={workspace_index} cycle={cycle} action=ctrl+d(extra:{extra_ctrl_d}/{CTRL_D_MAX_EXTRA},surface={retry_right_panel_id})"
|
||||
)
|
||||
next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S)
|
||||
|
||||
sampled += 1
|
||||
if LAYOUT_POLL_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_POLL_SLEEP_S)
|
||||
|
||||
raise cmuxError(
|
||||
f"Timed out waiting for 1 pane after ctrl+d "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, "
|
||||
f"extra_ctrl_d={extra_ctrl_d}, last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); "
|
||||
f"recent_actions={list(recent_actions)}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rng = random.Random(FUZZ_SEED)
|
||||
recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL))
|
||||
total_samples = 0
|
||||
total_cycles = 0
|
||||
total_extra_ctrl_d = 0
|
||||
timeout_state: dict[str, int] = {"count": 0}
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
c.activate_app()
|
||||
|
||||
for workspace_index in range(1, WORKSPACES + 1):
|
||||
ws = c.new_workspace()
|
||||
c.select_workspace(ws)
|
||||
c.activate_app()
|
||||
time.sleep(0.08)
|
||||
|
||||
start = _safe_layout_debug(c, timeout_state=timeout_state, context=f"workspace={workspace_index} start")
|
||||
baseline = _container_frame(start)
|
||||
start_panes = _pane_count(start)
|
||||
if start_panes != 1:
|
||||
raise cmuxError(f"New workspace did not start as single pane (workspace={workspace_index}, panes={start_panes})")
|
||||
|
||||
for cycle in range(1, CYCLES_PER_WORKSPACE + 1):
|
||||
total_cycles += 1
|
||||
|
||||
if PRE_ACTION_JITTER_MAX_S > 0:
|
||||
time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S))
|
||||
|
||||
recent_actions.append(f"ws={workspace_index} cycle={cycle} action=cmd+d")
|
||||
c.simulate_shortcut("cmd+d")
|
||||
|
||||
after_split, sampled = _wait_for_panes(
|
||||
c,
|
||||
target_panes=2,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase="after_cmd+d",
|
||||
timeout_s=TRANSITION_TIMEOUT_S,
|
||||
trace=list(recent_actions),
|
||||
timeout_state=timeout_state,
|
||||
)
|
||||
total_samples += sampled
|
||||
_assert_two_panes_left_right(after_split, workspace_index=workspace_index, cycle=cycle, trace=list(recent_actions))
|
||||
|
||||
hold_split = rng.uniform(HOLD_MIN_S, HOLD_MAX_S)
|
||||
total_samples += _sample_while(
|
||||
c,
|
||||
baseline=baseline,
|
||||
deadline=time.time() + hold_split,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase="hold_2pane",
|
||||
trace=list(recent_actions),
|
||||
timeout_state=timeout_state,
|
||||
)
|
||||
|
||||
if PRE_ACTION_JITTER_MAX_S > 0:
|
||||
time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S))
|
||||
|
||||
right_panel_id = _rightmost_panel_id(after_split)
|
||||
recent_actions.append(f"ws={workspace_index} cycle={cycle} action=ctrl+d(surface={right_panel_id})")
|
||||
c.send_key_surface(right_panel_id, "ctrl-d")
|
||||
|
||||
_, sampled, extra_ctrl_d = _wait_for_single_pane_after_ctrl_d(
|
||||
c,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase="after_ctrl+d",
|
||||
timeout_s=TRANSITION_TIMEOUT_S,
|
||||
recent_actions=recent_actions,
|
||||
timeout_state=timeout_state,
|
||||
)
|
||||
total_samples += sampled
|
||||
total_extra_ctrl_d += extra_ctrl_d
|
||||
|
||||
hold_single = rng.uniform(HOLD_MIN_S, HOLD_MAX_S)
|
||||
total_samples += _sample_while(
|
||||
c,
|
||||
baseline=baseline,
|
||||
deadline=time.time() + hold_single,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase="hold_1pane",
|
||||
trace=list(recent_actions),
|
||||
timeout_state=timeout_state,
|
||||
)
|
||||
|
||||
c.close_workspace(ws)
|
||||
time.sleep(0.05)
|
||||
|
||||
print(
|
||||
"PASS: strict two-pane cmd+d/ctrl+d frame guard "
|
||||
f"(seed={FUZZ_SEED}, workspaces={WORKSPACES}, cycles={total_cycles}, samples={total_samples}, "
|
||||
f"extra_ctrl_d={total_extra_ctrl_d}, epsilon={EPSILON}, layout_timeouts={timeout_state.get('count', 0)})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 0dd965a75f02f7a358f87fd607a9e2034450a79c
|
||||
Subproject commit 21db26f8a6a0c7707af10da672c0d7cf07076c66
|
||||
|
|
@ -48,11 +48,21 @@ export async function CodeBlock({
|
|||
</div>
|
||||
)}
|
||||
<pre
|
||||
className={`bg-code-bg border border-border px-4 py-3 overflow-x-auto text-[13px] ${lineHeightClass} font-mono ${
|
||||
title ? "rounded-b-lg" : "rounded-lg"
|
||||
}`}
|
||||
className={`bg-code-bg border border-border px-4 py-3 overflow-x-auto text-[13px] ${lineHeightClass} ${
|
||||
variant === "ascii" ? "" : "font-mono "
|
||||
}${title ? "rounded-b-lg" : "rounded-lg"}`}
|
||||
style={
|
||||
variant === "ascii"
|
||||
? {
|
||||
fontFamily:
|
||||
"Menlo, Monaco, Consolas, 'Courier New', monospace",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<code>{children}</code>
|
||||
<code style={variant === "ascii" ? { fontFamily: "inherit" } : undefined}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function SiteHeader({
|
|||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-30 w-full bg-background/80 backdrop-blur-sm">
|
||||
<header className="sticky top-0 z-30 w-full bg-background">
|
||||
<div className="w-full max-w-6xl mx-auto flex items-center px-6 h-12">
|
||||
{/* Left: logo + section */}
|
||||
<div className="flex flex-1 items-center gap-3 min-w-0">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function DocsNav({ children }: { children: React.ReactNode }) {
|
|||
const { open, toggle, close, drawerRef, buttonRef } = useMobileDrawer();
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto flex px-4">
|
||||
<div className="max-w-6xl mx-auto flex px-0 md:px-4">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
|
|
@ -62,8 +62,8 @@ export function DocsNav({ children }: { children: React.ReactNode }) {
|
|||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="max-w-3xl px-6 pb-10 ml-0" data-dev="docs-content" style={{ paddingTop: 8 }}>
|
||||
<main className="flex-1 min-w-0 overflow-x-hidden">
|
||||
<div className="max-w-full px-6 pb-10 ml-0" data-dev="docs-content" style={{ paddingTop: 16 }}>
|
||||
<div className="docs-content text-[15px]">{children}</div>
|
||||
<DocsPager />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -159,10 +159,16 @@ body {
|
|||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.docs-content pre {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.docs-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Shiki dual theme */
|
||||
|
|
|
|||
|
|
@ -225,6 +225,9 @@ export default function Home() {
|
|||
<span className="text-muted group-hover:text-foreground transition-colors">
|
||||
"{t.text}"
|
||||
</span>
|
||||
{"translation" in t && t.translation && (
|
||||
<span className="text-muted/60 text-xs italic"> — {t.translation}</span>
|
||||
)}
|
||||
</a>
|
||||
{" "}
|
||||
<a
|
||||
|
|
@ -256,6 +259,14 @@ export default function Home() {
|
|||
<DownloadButton location="bottom" />
|
||||
<GitHubButton />
|
||||
</div>
|
||||
<div className="flex justify-center mt-6">
|
||||
<a
|
||||
href="/docs"
|
||||
className="text-sm text-muted hover:text-foreground transition-colors underline underline-offset-2 decoration-border hover:decoration-foreground"
|
||||
>
|
||||
Read the Docs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@ export const testimonials = [
|
|||
url: "https://x.com/schrockn/status/2025182278637207857",
|
||||
platform: "x" as const,
|
||||
},
|
||||
{
|
||||
name: "あさざ",
|
||||
handle: "@asaza_0928",
|
||||
avatar: "/avatars/asaza_0928.jpg",
|
||||
text: "cmux 良さそうすぎてついにバイバイ VSCode するときなのかもしれない",
|
||||
translation: "cmux looks so good it might finally be time to say goodbye to VSCode",
|
||||
url: "https://x.com/asaza_0928/status/2026057269075698015",
|
||||
platform: "x" as const,
|
||||
},
|
||||
{
|
||||
name: "johnthedebs",
|
||||
handle: "johnthedebs",
|
||||
|
|
@ -49,6 +58,32 @@ export const testimonials = [
|
|||
url: "https://www.reddit.com/r/ClaudeCode/comments/1r9g45u/comment/o6sxbr3/",
|
||||
platform: "reddit" as const,
|
||||
},
|
||||
{
|
||||
name: "Norihiro Narayama",
|
||||
handle: "@northprint",
|
||||
avatar: "/avatars/northprint.jpg",
|
||||
text: "cmux良さそうなので入れてみたけれど、良い",
|
||||
translation: "Tried cmux since it looked good — it's good",
|
||||
url: "https://x.com/northprint/status/2025740286677434581",
|
||||
platform: "x" as const,
|
||||
},
|
||||
{
|
||||
name: "Kishore Neelamegam",
|
||||
handle: "@indykish",
|
||||
avatar: "/avatars/indykish.jpg",
|
||||
text: "cmux is pretty good.",
|
||||
url: "https://x.com/indykish/status/2025318347970412673",
|
||||
platform: "x" as const,
|
||||
},
|
||||
{
|
||||
name: "かたりん",
|
||||
handle: "@kataring",
|
||||
avatar: "/avatars/kataring.jpg",
|
||||
text: "cmux.dev に乗り換えた",
|
||||
translation: "Switched to cmux.dev",
|
||||
url: "https://x.com/kataring/status/2026189035056832718",
|
||||
platform: "x" as const,
|
||||
},
|
||||
];
|
||||
|
||||
export type Testimonial = (typeof testimonials)[number];
|
||||
|
|
@ -159,6 +194,11 @@ export function TestimonialCard({
|
|||
<p className="text-[15px] leading-relaxed text-muted group-hover:text-foreground transition-colors">
|
||||
{testimonial.text}
|
||||
</p>
|
||||
{"translation" in testimonial && testimonial.translation && (
|
||||
<p className="text-xs text-muted/60 mt-1.5 italic">
|
||||
{testimonial.translation}
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
BIN
web/public/avatars/asaza_0928.jpg
Normal file
BIN
web/public/avatars/asaza_0928.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
web/public/avatars/indykish.jpg
Normal file
BIN
web/public/avatars/indykish.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
web/public/avatars/kataring.jpg
Normal file
BIN
web/public/avatars/kataring.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
web/public/avatars/northprint.jpg
Normal file
BIN
web/public/avatars/northprint.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9 KiB |
Loading…
Add table
Add a link
Reference in a new issue