Merge origin/main into issue-668-command-palette-search-speed

This commit is contained in:
Lawrence Chen 2026-03-06 03:36:39 -08:00
commit 2c2da79138
45 changed files with 7910 additions and 910 deletions

View file

@ -73,11 +73,17 @@ jobs:
- name: Clean DerivedData
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .ci-source-packages
key: spm-${{ matrix.os }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-${{ matrix.os }}-
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
rm -rf "$SOURCE_PACKAGES_DIR"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do

View file

@ -101,11 +101,17 @@ jobs:
# Remove stale build cache to avoid incremental build errors
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .ci-source-packages
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
rm -rf "$SOURCE_PACKAGES_DIR"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
@ -226,11 +232,17 @@ jobs:
- name: Clean DerivedData
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .ci-source-packages
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
rm -rf "$SOURCE_PACKAGES_DIR"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do

View file

@ -142,13 +142,12 @@ jobs:
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
- name: Configure SwiftPM cache
run: |
set -euo pipefail
CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}"
rm -rf "$CACHE_DIR"
mkdir -p "$CACHE_DIR"
echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV"
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .spm-cache
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Derive Sparkle public key from private key
env:
@ -164,7 +163,9 @@ jobs:
- name: Build app (Release)
run: |
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
xcodebuild -scheme cmux -configuration Release -derivedDataPath build \
-clonedSourcePackagesDirPath .spm-cache \
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
- name: Inject nightly identity and metadata
run: |

View file

@ -129,6 +129,14 @@ jobs:
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
- name: Cache Swift packages
if: steps.guard_release_assets.outputs.skip_all != 'true'
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .spm-cache
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Derive Sparkle public key from private key
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
@ -145,7 +153,9 @@ jobs:
- name: Build app (Release)
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
xcodebuild -scheme cmux -configuration Release -derivedDataPath build \
-clonedSourcePackagesDirPath .spm-cache \
CODE_SIGNING_ALLOWED=NO build
- name: Inject Sparkle keys into Info.plist
if: steps.guard_release_assets.outputs.skip_all != 'true'

View file

@ -151,11 +151,17 @@ jobs:
- name: Clean DerivedData
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .ci-source-packages
key: spm-${{ inputs.runner || 'macos-15' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-${{ inputs.runner || 'macos-15' }}-
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
rm -rf "$SOURCE_PACKAGES_DIR"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \

View file

@ -2622,7 +2622,34 @@ struct CMUXCLI {
throw CLIError(message: "browser requires a subcommand")
}
let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(commandArgs, name: "--surface")
var effectiveJSONOutput = jsonOutput
var effectiveIDFormat = idFormat
var browserArgs = commandArgs
// Browser-skill examples often place output flags at the end of the command.
// Strip trailing display flags so they don't become part of a URL or selector.
while !browserArgs.isEmpty {
if browserArgs.last == "--json" {
effectiveJSONOutput = true
browserArgs.removeLast()
continue
}
if browserArgs.count >= 2,
browserArgs[browserArgs.count - 2] == "--id-format" {
let raw = browserArgs.last!
guard let parsed = try CLIIDFormat.parse(raw) else {
throw CLIError(message: "--id-format must be one of: refs, uuids, both")
}
effectiveIDFormat = parsed
browserArgs.removeLast(2)
continue
}
break
}
let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(browserArgs, name: "--surface")
var surfaceRaw = surfaceOpt
var args = argsWithoutSurfaceFlag
@ -2651,8 +2678,8 @@ struct CMUXCLI {
}
func output(_ payload: [String: Any], fallback: String) {
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
return
}
print(fallback)
@ -2808,8 +2835,8 @@ struct CMUXCLI {
}
}
let payload = try client.sendV2(method: "browser.open_split", params: params)
let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown"
let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown"
let surfaceText = formatHandle(payload, kind: "surface", idFormat: effectiveIDFormat) ?? "unknown"
let paneText = formatHandle(payload, kind: "pane", idFormat: effectiveIDFormat) ?? "unknown"
let placement = ((payload["created_split"] as? Bool) == true) ? "split" : "reuse"
output(payload, fallback: "OK surface=\(surfaceText) pane=\(paneText) placement=\(placement)")
return
@ -2817,12 +2844,17 @@ struct CMUXCLI {
if subcommand == "goto" || subcommand == "navigate" {
let sid = try requireSurface()
let url = subArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
var urlArgs = subArgs
let snapshotAfter = urlArgs.last == "--snapshot-after"
if snapshotAfter {
urlArgs.removeLast()
}
let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !url.isEmpty else {
throw CLIError(message: "browser \(subcommand) requires a URL")
}
var params: [String: Any] = ["surface_id": sid, "url": url]
if hasFlag(subArgs, name: "--snapshot-after") {
if snapshotAfter {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: "browser.navigate", params: params)
@ -2849,8 +2881,8 @@ struct CMUXCLI {
if subcommand == "url" || subcommand == "get-url" {
let sid = try requireSurface()
let payload = try client.sendV2(method: "browser.url.get", params: ["surface_id": sid])
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else {
print((payload["url"] as? String) ?? "")
}
@ -2867,8 +2899,8 @@ struct CMUXCLI {
if ["is-webview-focused", "is_webview_focused"].contains(subcommand) {
let sid = try requireSurface()
let payload = try client.sendV2(method: "browser.is_webview_focused", params: ["surface_id": sid])
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else {
print((payload["focused"] as? Bool) == true ? "true" : "false")
}
@ -2901,8 +2933,8 @@ struct CMUXCLI {
}
let payload = try client.sendV2(method: "browser.snapshot", params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else {
print(displaySnapshotText(payload))
}
@ -3113,7 +3145,7 @@ struct CMUXCLI {
let sid = try requireSurface()
let (outPathOpt, _) = parseOption(subArgs, name: "--out")
let localJSONOutput = hasFlag(subArgs, name: "--json")
let outputAsJSON = jsonOutput || localJSONOutput
let outputAsJSON = effectiveJSONOutput || localJSONOutput
var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
func fileURL(fromPath rawPath: String) -> URL {
@ -3228,7 +3260,7 @@ struct CMUXCLI {
}
if outputAsJSON {
let formattedPayload = formatIDs(payload, mode: idFormat)
let formattedPayload = formatIDs(payload, mode: effectiveIDFormat)
if var outputPayload = formattedPayload as? [String: Any] {
if hasText(screenshotPath) || hasText(screenshotURL) {
outputPayload.removeValue(forKey: "png_base64")
@ -3302,8 +3334,8 @@ struct CMUXCLI {
"styles": "browser.get.styles",
]
let payload = try client.sendV2(method: methodMap[getVerb]!, params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else if let value = payload["value"] {
if let str = value as? String {
print(str)
@ -3342,8 +3374,8 @@ struct CMUXCLI {
throw CLIError(message: "Unsupported browser is subcommand: \(isVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid, "selector": selector])
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else if let value = payload["value"] {
print("\(value)")
} else {

View file

@ -73,6 +73,7 @@
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 */; };
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; };
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; };
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; };
@ -204,6 +205,7 @@
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>"; };
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
@ -435,6 +437,7 @@
B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */,
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */,
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
@ -670,6 +673,7 @@
B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */,
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */,
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,

View file

@ -567,6 +567,584 @@
}
}
},
"debug.devBuildBanner.show": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show Dev Build Banner"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "開発ビルドバナーを表示"
}
}
}
},
"debug.devBuildBanner.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "THIS IS A DEV BUILD"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "これは開発ビルドです"
}
}
}
},
"sidebar.help.button": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Help"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ヘルプ"
}
}
}
},
"sidebar.help.changelog": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Changelog"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "更新履歴"
}
}
}
},
"sidebar.help.githubIssues": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "GitHub Issues"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "GitHub Issues"
}
}
}
},
"sidebar.help.sendFeedback": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Send Feedback"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックを送信"
}
}
}
},
"sidebar.help.feedback.attachImages": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Attach Images"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "画像を添付"
}
}
}
},
"sidebar.help.feedback.attachImages.prompt": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Attach"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "添付"
}
}
}
},
"sidebar.help.feedback.attachImages.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Attach Images"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "画像を添付"
}
}
}
},
"sidebar.help.feedback.attachmentsHint": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Up to 10 images."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "画像は最大10枚まで添付できます。"
}
}
}
},
"sidebar.help.feedback.cancel": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Cancel"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "キャンセル"
}
}
}
},
"sidebar.help.feedback.connectionError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn't send feedback. Check your connection and try again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックを送信できませんでした。接続を確認して、もう一度お試しください。"
}
}
}
},
"sidebar.help.feedback.done": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Done"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "完了"
}
}
}
},
"sidebar.help.feedback.email": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Your Email"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メールアドレス"
}
}
}
},
"sidebar.help.feedback.emailPlaceholder": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "you@example.com"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "you@example.com"
}
}
}
},
"sidebar.help.feedback.emptyMessage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a message before sending."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "送信する前にメッセージを入力してください。"
}
}
}
},
"sidebar.help.feedback.endpointError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Feedback is unavailable right now. Email founders@manaflow.com instead."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "現在フィードバックを送信できません。代わりに founders@manaflow.com までメールしてください。"
}
}
}
},
"sidebar.help.feedback.genericError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Couldn't send feedback. Please try again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックを送信できませんでした。もう一度お試しください。"
}
}
}
},
"sidebar.help.feedback.imageTooLarge": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Each image must be 4 MB or smaller."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "各画像は 4 MB 以下にしてください。"
}
}
}
},
"sidebar.help.feedback.invalidEmail": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a valid email address."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "有効なメールアドレスを入力してください。"
}
}
}
},
"sidebar.help.feedback.invalidImageSelection": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "One of the selected files could not be attached."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "選択したファイルのうち1つを添付できませんでした。"
}
}
}
},
"sidebar.help.feedback.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Message"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メッセージ"
}
}
}
},
"sidebar.help.feedback.messagePlaceholder": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Share feedback, feature requests, or issues."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバック、機能要望、不具合をお知らせください。"
}
}
}
},
"sidebar.help.feedback.messageTooLong": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Your message is too long."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メッセージが長すぎます。"
}
}
}
},
"sidebar.help.feedback.note": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "You can also reach us at founders@manaflow.com."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "founders@manaflow.com 宛てに直接ご連絡いただくこともできます。"
}
}
}
},
"sidebar.help.feedback.rateLimited": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Too many feedback attempts. Please try again later."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックの送信回数が多すぎます。しばらくしてからもう一度お試しください。"
}
}
}
},
"sidebar.help.feedback.removeAttachment": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Remove"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "削除"
}
}
}
},
"sidebar.help.feedback.send": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Send"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "送信"
}
}
}
},
"sidebar.help.feedback.successBody": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "You can also reach us at founders@manaflow.com."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "founders@manaflow.com 宛てに直接ご連絡いただくこともできます。"
}
}
}
},
"sidebar.help.feedback.successTitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Thanks for the feedback."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックありがとうございます。"
}
}
}
},
"sidebar.help.feedback.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Send Feedback"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フィードバックを送信"
}
}
}
},
"sidebar.help.feedback.tooManyImages": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "You can attach up to 10 images."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "画像は最大10枚まで添付できます。"
}
}
}
},
"sidebar.help.feedback.totalImagesTooLarge": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "These images are too large to send together. Remove a few and try again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "これらの画像はまとめて送信するには大きすぎます。いくつか削除してもう一度お試しください。"
}
}
}
},
"sidebar.help.feedback.validationError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Check your message and attachments, then try again."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メッセージと添付ファイルを確認して、もう一度お試しください。"
}
}
}
},
"about.github": {
"extractionState": "manual",
"localizations": {
@ -35145,119 +35723,6 @@
}
}
},
"menu.updateLogs.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Update Logs"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アップデートログ"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "更新日志"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "更新記錄"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "업데이트 로그"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Update-Protokolle"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Registros de actualización"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Journaux de mise à jour"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Log aggiornamento"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Opdateringslogfiler"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Dzienniki aktualizacji"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Журналы обновлений"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Logovi ažuriranja"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "سجلات التحديث"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Oppdateringslogger"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Logs de Atualização"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "บันทึกการอัปเดต"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Güncelleme Günlükleri"
}
}
}
},
"menu.view.actualSize": {
"extractionState": "manual",
"localizations": {

View file

@ -1242,33 +1242,6 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
) != nil
}
func shouldRouteTerminalCommandShortcutToGhostty(
flags: NSEvent.ModifierFlags,
chars: String,
keyCode: UInt16,
terminalHasSelection: Bool
) -> Bool {
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
guard normalizedFlags.contains(.command) else { return false }
let normalizedChars = chars.lowercased()
if normalizedFlags == [.command] {
// Keep Preferences (Cmd+,) menu-routed even when a terminal is focused.
if normalizedChars == "," || keyCode == 43 {
return false
}
// Preserve standard copy behavior when text is selected in the terminal.
if (normalizedChars == "c" || keyCode == 8), terminalHasSelection {
return false
}
}
return true
}
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
guard let responder else { return nil }
if let ghosttyView = responder as? GhosttyNSView {
@ -1721,6 +1694,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
PostHogAnalytics.shared.startIfNeeded()
}
let forceDuplicateLaunchObserver = env["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1"
// UI tests frequently time out waiting for the main window if we do heavyweight
// LaunchServices registration / single-instance enforcement synchronously at startup.
// Skip these during XCTest (the app-under-test) so the window can appear quickly.
@ -1731,6 +1706,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
self.enforceSingleInstance()
self.observeDuplicateLaunches()
}
} else if forceDuplicateLaunchObserver {
// Some UI regressions specifically exercise launch-observer behavior while still
// running under XCTest. Allow an explicit opt-in for those cases only.
DispatchQueue.main.async { [weak self] in
self?.observeDuplicateLaunches()
}
}
NSWindow.allowsAutomaticWindowTabbing = false
disableNativeTabbingShortcut()
@ -1838,6 +1819,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
@ -4858,8 +4840,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@MainActor
static func presentPreferencesWindow(
showFallbackSettingsWindow: @MainActor () -> Void = {
SettingsWindowController.shared.show()
navigationTarget: SettingsNavigationTarget? = nil,
showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in
SettingsWindowController.shared.show(navigationTarget: target)
},
activateApplication: @MainActor () -> Void = {
NSApp.activate(ignoringOtherApps: true)
@ -4868,7 +4851,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#if DEBUG
dlog("settings.open.present path=customWindowDirect")
#endif
showFallbackSettingsWindow()
showFallbackSettingsWindow(navigationTarget)
activateApplication()
#if DEBUG
dlog("settings.open.present activate=1")
@ -4876,11 +4859,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
@MainActor
func openPreferencesWindow(debugSource: String) {
func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) {
#if DEBUG
dlog("settings.open.request source=\(debugSource)")
#endif
Self.presentPreferencesWindow()
Self.presentPreferencesWindow(navigationTarget: navigationTarget)
}
@objc func openPreferencesWindow() {
@ -5777,19 +5760,64 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
try? FileManager.default.removeItem(atPath: path)
let deadline = Date().addingTimeInterval(8.0)
let contextDeadline = Date().addingTimeInterval(8.0)
func waitForContexts(minCount: Int, _ completion: @escaping () -> Void) {
if mainWindowContexts.count >= minCount,
mainWindowContexts.values.allSatisfy({ $0.window != nil }) {
completion()
return
}
guard Date() < deadline else { return }
guard Date() < contextDeadline else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
waitForContexts(minCount: minCount, completion)
}
}
func waitForSurfaceId(
on tabManager: TabManager,
tabId: UUID,
timeout: TimeInterval = 8.0,
_ completion: @escaping (UUID) -> Void
) {
let deadline = Date().addingTimeInterval(timeout)
func resolvedSurfaceId() -> UUID? {
if let surfaceId = tabManager.focusedPanelId(for: tabId) {
return surfaceId
}
guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else {
return nil
}
if let terminalPanelId = workspace.focusedTerminalPanel?.id {
return terminalPanelId
}
if let terminalPanelId = workspace.terminalPanelForConfigInheritance()?.id {
return terminalPanelId
}
return workspace.panels.values
.compactMap { ($0 as? TerminalPanel)?.id }
.sorted(by: { $0.uuidString < $1.uuidString })
.first
}
func poll() {
if let surfaceId = resolvedSurfaceId() {
completion(surfaceId)
return
}
guard Date() < deadline else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
poll()
}
}
poll()
}
waitForContexts(minCount: 1) { [weak self] in
guard let self else { return }
guard let window1 = self.mainWindowContexts.values.first else { return }
@ -5803,39 +5831,193 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let contexts = Array(self.mainWindowContexts.values)
guard let window2 = contexts.first(where: { $0.windowId != window1.windowId }) else { return }
guard let tabId2 = window2.tabManager.selectedTabId ?? window2.tabManager.tabs.first?.id else { return }
guard let store = self.notificationStore else { return }
waitForSurfaceId(on: window1.tabManager, tabId: tabId1) { [weak self] surfaceId1 in
guard let self else { return }
waitForSurfaceId(on: window2.tabManager, tabId: tabId2) { [weak self] surfaceId2 in
guard let self else { return }
guard let store = self.notificationStore else { return }
// Ensure the target window is currently showing the Notifications overlay,
// so opening a notification must switch it back to the terminal UI.
window2.sidebarSelectionState.selection = .notifications
// Ensure the target window is currently showing the Notifications overlay,
// so opening a notification must switch it back to the terminal UI.
window2.sidebarSelectionState.selection = .notifications
// Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused.
let prevOverride = AppFocusState.overrideIsFocused
AppFocusState.overrideIsFocused = false
store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "")
AppFocusState.overrideIsFocused = prevOverride
// Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused.
let prevOverride = AppFocusState.overrideIsFocused
AppFocusState.overrideIsFocused = false
store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "")
AppFocusState.overrideIsFocused = prevOverride
// Insert after W2 so it becomes "latest unread" (first in list).
store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "")
// Insert after W2 so it becomes "latest unread" (first in list).
store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "")
let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" })
let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" })
let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" })
let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" })
self.writeMultiWindowNotificationTestData([
"window1Id": window1.windowId.uuidString,
"window2Id": window2.windowId.uuidString,
"window2InitialSidebarSelection": "notifications",
"tabId1": tabId1.uuidString,
"tabId2": tabId2.uuidString,
"notifId1": notif1?.id.uuidString ?? "",
"notifId2": notif2?.id.uuidString ?? "",
"expectedLatestWindowId": window1.windowId.uuidString,
"expectedLatestTabId": tabId1.uuidString,
], at: path)
self.writeMultiWindowNotificationTestData([
"window1Id": window1.windowId.uuidString,
"window2Id": window2.windowId.uuidString,
"window2InitialSidebarSelection": "notifications",
"tabId1": tabId1.uuidString,
"tabId2": tabId2.uuidString,
"surfaceId1": surfaceId1.uuidString,
"surfaceId2": surfaceId2.uuidString,
"notifId1": notif1?.id.uuidString ?? "",
"notifId2": notif2?.id.uuidString ?? "",
"expectedLatestWindowId": window1.windowId.uuidString,
"expectedLatestTabId": tabId1.uuidString,
], at: path)
self.prepareMultiWindowNotificationSourceTerminalIfNeeded(
at: path,
windowId: window1.windowId,
tabManager: window1.tabManager,
tabId: tabId1,
surfaceId: surfaceId1
)
self.publishMultiWindowNotificationSocketStateIfNeeded(at: path)
}
}
}
}
}
private func prepareMultiWindowNotificationSourceTerminalIfNeeded(
at path: String,
windowId: UUID,
tabManager: TabManager,
tabId: UUID,
surfaceId: UUID
) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_NOTIFY_SOURCE_TERMINAL_READY"] == "1" else { return }
writeMultiWindowNotificationTestData([
"sourceTerminalReady": "pending",
"sourceTerminalFocusFailure": "",
], at: path)
let deadline = Date().addingTimeInterval(8.0)
func publish(ready: Bool, failure: String = "") {
writeMultiWindowNotificationTestData([
"sourceTerminalReady": ready ? "1" : "0",
"sourceTerminalFocusFailure": failure,
], at: path)
}
func poll() {
guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else {
publish(ready: false, failure: "workspace_missing")
return
}
guard let terminalPanel = workspace.terminalPanel(for: surfaceId) else {
publish(ready: false, failure: "terminal_missing")
return
}
let isWindowFrontmost = {
guard let window = self.mainWindow(for: windowId) else { return false }
return NSApp.keyWindow === window || NSApp.mainWindow === window
}()
if isWindowFrontmost && terminalPanel.hostedView.isSurfaceViewFirstResponder() {
publish(ready: true)
return
}
guard Date() < deadline else {
publish(
ready: false,
failure: isWindowFrontmost ? "terminal_not_first_responder" : "window_not_frontmost"
)
return
}
_ = self.focusMainWindow(windowId: windowId)
if let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
tabManager.selectTab(tab)
tabManager.focusSurface(tabId: tabId, surfaceId: surfaceId)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
poll()
}
}
poll()
}
private func publishMultiWindowNotificationSocketStateIfNeeded(at path: String) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return }
guard let config = socketListenerConfigurationIfEnabled() else {
writeMultiWindowNotificationTestData([
"socketExpectedPath": env["CMUX_SOCKET_PATH"] ?? "",
"socketMode": "off",
"socketReady": "0",
"socketPingResponse": "",
"socketIsRunning": "0",
"socketAcceptLoopAlive": "0",
"socketPathMatches": "0",
"socketPathExists": "0",
"socketFailureSignals": "socket_disabled",
], at: path)
return
}
writeMultiWindowNotificationTestData([
"socketExpectedPath": config.path,
"socketMode": config.mode.rawValue,
"socketReady": "pending",
"socketPingResponse": "",
], at: path)
restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup")
let deadline = Date().addingTimeInterval(20.0)
func publish() {
let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path)
let isTimedOut = Date() >= deadline
let socketPath = config.path
let socketMode = config.mode.rawValue
let dataPath = path
DispatchQueue.global(qos: .utility).async { [weak self] in
let pingResponse = health.isHealthy
? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0)
: nil
let isReady = health.isHealthy && pingResponse == "PONG"
let failureSignals = {
var signals = health.failureSignals
if health.isHealthy && pingResponse != "PONG" {
signals.append("ping_timeout")
}
return signals.joined(separator: ",")
}()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.writeMultiWindowNotificationTestData([
"socketExpectedPath": socketPath,
"socketMode": socketMode,
"socketReady": isReady ? "1" : (isTimedOut ? "0" : "pending"),
"socketPingResponse": pingResponse ?? "",
"socketIsRunning": health.isRunning ? "1" : "0",
"socketAcceptLoopAlive": health.acceptLoopAlive ? "1" : "0",
"socketPathMatches": health.socketPathMatches ? "1" : "0",
"socketPathExists": health.socketPathExists ? "1" : "0",
"socketFailureSignals": failureSignals,
], at: dataPath)
guard !isTimedOut, !isReady else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
publish()
}
}
}
}
publish()
}
private func writeMultiWindowNotificationTestData(_ updates: [String: String], at path: String) {
var payload = loadMultiWindowNotificationTestData(at: path)
for (key, value) in updates {
@ -6610,6 +6792,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .sendFeedback)) {
guard let targetContext = preferredMainWindowContextForShortcuts(event: event),
let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else {
return false
}
setActiveMainWindow(targetWindow)
bringToFront(targetWindow)
NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow)
return true
}
// Check Jump to Unread shortcut
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .jumpToUnread)) {
#if DEBUG
@ -7850,6 +8043,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func observeDuplicateLaunches() {
guard let bundleId = Bundle.main.bundleIdentifier else { return }
let embeddedCLIURL = Bundle.main.bundleURL
.appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false)
.standardizedFileURL
.resolvingSymlinksInPath()
let currentPid = ProcessInfo.processInfo.processIdentifier
workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
@ -7860,6 +8057,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard self != nil else { return }
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return }
if let executableURL = app.executableURL?
.standardizedFileURL
.resolvingSymlinksInPath(),
executableURL == embeddedCLIURL {
return
}
app.terminate()
if !app.isTerminated {
@ -8155,6 +8358,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
#endif
if let notificationId, let store = notificationStore {
markReadIfFocused(
notificationId: notificationId,
tabId: tabId,
surfaceId: surfaceId,
tabManager: context.tabManager,
notificationStore: store
)
}
#if DEBUG
recordMultiWindowNotificationFocusIfNeeded(
windowId: context.windowId,
@ -8208,6 +8421,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
#endif
if let notificationId, let store = notificationStore {
markReadIfFocused(
notificationId: notificationId,
tabId: tabId,
surfaceId: surfaceId,
tabManager: tabManager,
notificationStore: store
)
}
#if DEBUG
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"])
@ -8267,6 +8489,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
}
private func markReadIfFocused(
notificationId: UUID,
tabId: UUID,
surfaceId: UUID?,
tabManager: TabManager,
notificationStore: TerminalNotificationStore
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
guard tabManager.selectedTabId == tabId else { return }
if let surfaceId {
guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return }
}
notificationStore.markRead(id: notificationId)
}
}
#if DEBUG
private func recordMultiWindowNotificationOpenFailureIfNeeded(
tabId: UUID,
@ -9177,23 +9415,6 @@ private extension NSWindow {
return true
}
// Support custom tmux prefixes (for example Cmd+C): when the terminal is focused
// and no app-level shortcut matched, prefer forwarding Command-key input to the
// terminal rather than consuming it as a menu key equivalent.
if let ghosttyView = firstResponderGhosttyView,
shouldRouteTerminalCommandShortcutToGhostty(
flags: event.modifierFlags,
chars: event.charactersIgnoringModifiers ?? "",
keyCode: event.keyCode,
terminalHasSelection: ghosttyView.terminalSurface?.hasSelection() ?? false
) {
ghosttyView.keyDown(with: event)
#if DEBUG
dlog(" → ghostty command passthrough")
#endif
return true
}
// When the terminal is focused, skip the full NSWindow.performKeyEquivalent
// (which walks the SwiftUI content view hierarchy) and dispatch Command-key
// events directly to the main menu. This avoids the broken SwiftUI focus path.
@ -9302,12 +9523,32 @@ private extension NSWindow {
if let webView = candidate as? CmuxWebView {
return webView
}
if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"),
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
return portalWebView
}
current = candidate.superview
}
return nil
}
private static func cmuxUniqueBrowserWebView(in root: NSView) -> CmuxWebView? {
var stack: [NSView] = [root]
var found: CmuxWebView?
while let current = stack.popLast() {
if let webView = current as? CmuxWebView {
if found == nil {
found = webView
} else if found !== webView {
return nil
}
}
stack.append(contentsOf: current.subviews)
}
return found
}
private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? {
#if DEBUG
if let override = cmuxFirstResponderGuardCurrentEventOverride {
@ -9323,7 +9564,22 @@ private extension NSWindow {
return override
}
#endif
return window.contentView?.hitTest(event.locationInWindow)
guard let contentView = window.contentView else { return nil }
if contentView.className == "NSGlassEffectView" {
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
}
if let themeFrame = contentView.superview {
let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil)
if let hit = themeFrame.hitTest(pointInTheme) {
return hit
}
}
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
}
private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ enum KeyboardShortcutSettings {
case newWindow
case closeWindow
case openFolder
case sendFeedback
case showNotifications
case jumpToUnread
case triggerFlash
@ -50,6 +51,7 @@ enum KeyboardShortcutSettings {
case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window")
case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window")
case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder")
case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback")
case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications")
case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread")
case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel")
@ -84,6 +86,7 @@ enum KeyboardShortcutSettings {
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .openFolder: return "shortcut.openFolder"
case .sendFeedback: return "shortcut.sendFeedback"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
@ -123,6 +126,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
case .openFolder:
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
case .sendFeedback:
return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:

View file

@ -1244,6 +1244,15 @@ final class BrowserSearchState: ObservableObject {
}
}
final class BrowserPortalAnchorView: NSView {
override var acceptsFirstResponder: Bool { false }
override var isOpaque: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
@MainActor
final class BrowserPanel: Panel, ObservableObject {
/// Shared process pool for cookie sharing across all browser panels
@ -1481,6 +1490,7 @@ final class BrowserPanel: Panel, ObservableObject {
}
}
private var searchNeedleCancellable: AnyCancellable?
let portalAnchorView = BrowserPortalAnchorView(frame: .zero)
private var webViewCancellables = Set<AnyCancellable>()
private var navigationDelegate: BrowserNavigationDelegate?
private var uiDelegate: BrowserUIDelegate?
@ -2587,7 +2597,7 @@ extension BrowserPanel {
/// while its container is off-window. Avoid detaching in that transient phase if
/// DevTools is intended to remain open, because detach/reattach can blank inspector content.
func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool {
preferredDeveloperToolsVisible
preferredDeveloperToolsVisible && !hasSideDockedDeveloperToolsLayout()
}
func requestDeveloperToolsRefreshAfterNextAttach(reason: String) {
@ -3045,6 +3055,7 @@ extension BrowserPanel {
let containerType = container.map { String(describing: type(of: $0)) } ?? "nil"
return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)"
}
}
#endif
@ -3069,6 +3080,71 @@ private extension BrowserPanel {
}
return false
}
func hasSideDockedDeveloperToolsLayout() -> Bool {
guard let container = webView.superview else { return false }
return Self.visibleDescendants(in: container)
.filter { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) }
.contains { inspectorCandidate in
hasSideDockedInspectorSibling(startingAt: inspectorCandidate, root: container)
}
}
func hasSideDockedInspectorSibling(startingAt inspectorLeaf: NSView, root: NSView) -> Bool {
var current: NSView? = inspectorLeaf
while let inspectorView = current, inspectorView !== root {
guard let containerView = inspectorView.superview else { break }
let hasSideDockedSibling = containerView.subviews.contains { candidate in
guard Self.isVisibleSideDockSiblingCandidate(candidate) else { return false }
guard candidate !== inspectorView else { return false }
let horizontallyAdjacent =
candidate.frame.maxX <= inspectorView.frame.minX + 1 ||
candidate.frame.minX >= inspectorView.frame.maxX - 1
guard horizontallyAdjacent else { return false }
return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8
}
if hasSideDockedSibling {
return true
}
current = containerView
}
return false
}
static func visibleDescendants(in root: NSView) -> [NSView] {
var descendants: [NSView] = []
var stack = Array(root.subviews.reversed())
while let view = stack.popLast() {
descendants.append(view)
stack.append(contentsOf: view.subviews.reversed())
}
return descendants
}
static func isInspectorView(_ view: NSView) -> Bool {
String(describing: type(of: view)).contains("WKInspector")
}
static func isVisibleSideDockInspectorCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
view.frame.height > 1
}
static func isVisibleSideDockSiblingCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
view.frame.height > 1
}
static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
}
}
private extension WKWebView {

View file

@ -230,6 +230,7 @@ struct BrowserPanelView: View {
@State private var focusFlashOpacity: Double = 0.0
@State private var focusFlashAnimationGeneration: Int = 0
@State private var omnibarPillFrame: CGRect = .zero
@State private var addressBarHeight: CGFloat = 0
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var isBrowserThemeMenuPresented = false
@State private var ghosttyBackgroundGeneration: Int = 0
@ -298,6 +299,10 @@ struct BrowserPanelView: View {
)
}
private var browserContentAccessibilityIdentifier: String {
"BrowserPanelContent.\(panel.id.uuidString)"
}
private var omnibarPillBackgroundColor: NSColor {
resolvedBrowserOmnibarPillBackgroundColor(
for: browserChromeColorScheme,
@ -306,21 +311,16 @@ struct BrowserPanelView: View {
}
var body: some View {
// Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit
// container. Rendering it here can hide it behind the portal-hosted WKWebView.
VStack(spacing: 0) {
addressBar
webView
}
.overlay {
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 {
// Keep Cmd+F usable when the browser is still in the empty new-tab
// state (no WKWebView mounted yet). WebView-backed cases are hosted
// in AppKit by WebViewRepresentable to avoid layering/clipping issues.
// in AppKit by WindowBrowserPortal to avoid layering/clipping issues.
if !panel.shouldRenderWebView, let searchState = panel.searchState {
BrowserSearchOverlay(
panelId: panel.id,
@ -331,6 +331,13 @@ struct BrowserPanelView: View {
)
}
}
.overlay {
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) {
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
OmnibarSuggestionsView(
@ -357,6 +364,9 @@ struct BrowserPanelView: View {
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
omnibarPillFrame = frame
}
.onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in
addressBarHeight = height
}
.onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in
// Only handle clicks from our own webview.
guard let webView = note.object as? CmuxWebView else { return false }
@ -498,6 +508,15 @@ struct BrowserPanelView: View {
.padding(.horizontal, 8)
.padding(.vertical, addressBarVerticalPadding)
.background(browserChromeBackground)
.background {
GeometryReader { geo in
Color.clear
.preference(
key: BrowserAddressBarHeightPreferenceKey.self,
value: geo.size.height
)
}
}
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
.zIndex(1)
.environment(\.colorScheme, browserChromeColorScheme)
@ -738,17 +757,27 @@ struct BrowserPanelView: View {
if panel.shouldRenderWebView {
WebViewRepresentable(
panel: panel,
browserSearchState: panel.searchState,
shouldAttachWebView: isVisibleInUI,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority,
paneDropZone: paneDropZone
paneDropZone: paneDropZone,
searchOverlay: panel.searchState.map { searchState in
BrowserPortalSearchOverlayConfiguration(
panelId: panel.id,
searchState: searchState,
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() }
)
},
paneTopChromeHeight: addressBarHeight
)
// Keep the host stable for normal pane churn, but force a remount when
// BrowserPanel replaces its underlying WKWebView after process termination.
.id(panel.webViewInstanceID)
.contentShape(Rectangle())
.accessibilityIdentifier(browserContentAccessibilityIdentifier)
.simultaneousGesture(TapGesture().onEnded {
// Chrome-like behavior: clicking web content while editing the
// omnibar should commit blur and revert transient edits.
@ -759,6 +788,7 @@ struct BrowserPanelView: View {
} else {
Color(nsColor: browserChromeBackgroundColor)
.contentShape(Rectangle())
.accessibilityIdentifier(browserContentAccessibilityIdentifier)
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
@ -1939,6 +1969,14 @@ private struct OmnibarPillFramePreferenceKey: PreferenceKey {
}
}
private struct BrowserAddressBarHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
// MARK: - Omnibar State Machine
struct OmnibarState: Equatable {
@ -3038,12 +3076,13 @@ private struct OmnibarSuggestionsView: View {
/// NSViewRepresentable wrapper for WKWebView
struct WebViewRepresentable: NSViewRepresentable {
let panel: BrowserPanel
let browserSearchState: BrowserSearchState?
let shouldAttachWebView: Bool
let shouldFocusWebView: Bool
let isPanelFocused: Bool
let portalZPriority: Int
let paneDropZone: DropZone?
let searchOverlay: BrowserPortalSearchOverlayConfiguration?
let paneTopChromeHeight: CGFloat
final class Coordinator {
weak var panel: BrowserPanel?
@ -3052,47 +3091,362 @@ struct WebViewRepresentable: NSViewRepresentable {
var desiredPortalVisibleInUI: Bool = true
var desiredPortalZPriority: Int = 0
var lastPortalHostId: ObjectIdentifier?
var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
}
private final class HostContainerView: NSView {
final class HostContainerView: NSView {
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
private struct HostedInspectorDividerHit {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
}
private struct HostedInspectorDividerDragState {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
let initialWindowX: CGFloat
let initialPageFrame: NSRect
let initialInspectorFrame: NSRect
}
private enum DividerCursorKind: Equatable {
case vertical
var cursor: NSCursor { .resizeLeftRight }
}
private static let hostedInspectorDividerHitExpansion: CGFloat = 6
private static let minimumHostedInspectorWidth: CGFloat = 120
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
private var preferredHostedInspectorWidth: CGFloat?
private var isApplyingHostedInspectorLayout = false
#if DEBUG
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
private var hasLoggedMissingHostedInspectorCandidate = false
#endif
#if DEBUG
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
switch event?.type {
case .leftMouseDown, .leftMouseDragged, .leftMouseUp:
return true
default:
return false
}
}
private func debugLogHitTest(stage: String, point: NSPoint, passThrough: Bool, hitView: NSView?) {
let event = NSApp.currentEvent
guard Self.shouldLogPointerEvent(event) else { return }
let hitDesc: String = {
guard let hitView else { return "nil" }
let token = Unmanaged.passUnretained(hitView).toOpaque()
return "\(type(of: hitView))@\(token)"
}()
let hostRectInContent: NSRect = {
guard let window, let contentView = window.contentView else { return .zero }
return contentView.convert(bounds, from: self)
}()
dlog(
"browser.panel.host stage=\(stage) event=\(String(describing: event?.type)) " +
"point=\(String(format: "%.1f,%.1f", point.x, point.y)) pass=\(passThrough ? 1 : 0) " +
"hostFrameInContent=\(String(format: "%.1f,%.1f %.1fx%.1f", hostRectInContent.origin.x, hostRectInContent.origin.y, hostRectInContent.width, hostRectInContent.height)) " +
"hit=\(hitDesc)"
)
}
private static func debugObjectID(_ object: AnyObject?) -> String {
guard let object else { return "nil" }
return String(describing: Unmanaged.passUnretained(object).toOpaque())
}
private static func debugRect(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.width, rect.height)
}
private func debugLogHostedInspectorFrames(
stage: String,
point: NSPoint? = nil,
hit: HostedInspectorDividerHit
) {
let pointDesc = point.map { String(format: "%.1f,%.1f", $0.x, $0.y) } ?? "nil"
let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil"
dlog(
"browser.panel.hostedInspector stage=\(stage) point=\(pointDesc) " +
"host=\(Self.debugObjectID(self)) container=\(Self.debugObjectID(hit.containerView)) " +
"page=\(Self.debugObjectID(hit.pageView)) inspector=\(Self.debugObjectID(hit.inspectorView)) " +
"preferredWidth=\(preferredWidthDesc) " +
"hostFrame=\(Self.debugRect(frame)) hostBounds=\(Self.debugRect(bounds)) " +
"containerBounds=\(Self.debugRect(hit.containerView.bounds)) " +
"pageFrame=\(Self.debugRect(hit.pageView.frame)) " +
"inspectorFrame=\(Self.debugRect(hit.inspectorView.frame))"
)
}
private func debugLogHostedInspectorLayoutIfNeeded(reason: String) {
guard let hit = hostedInspectorDividerCandidate() else {
if !hasLoggedMissingHostedInspectorCandidate,
lastLoggedHostedInspectorFrames != nil || preferredHostedInspectorWidth != nil {
let preferredWidthDesc = preferredHostedInspectorWidth.map {
String(format: "%.1f", $0)
} ?? "nil"
lastLoggedHostedInspectorFrames = nil
hasLoggedMissingHostedInspectorCandidate = true
dlog(
"browser.panel.hostedInspector stage=\(reason).candidateMissing " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)"
)
}
return
}
hasLoggedMissingHostedInspectorCandidate = false
let nextFrames = (page: hit.pageView.frame, inspector: hit.inspectorView.frame)
if let lastLoggedHostedInspectorFrames,
Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page),
Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) {
return
}
lastLoggedHostedInspectorFrames = nextFrames
debugLogHostedInspectorFrames(stage: "\(reason).layout", hit: hit)
}
#endif
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.width - rhs.width) <= epsilon &&
abs(lhs.height - rhs.height) <= epsilon
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearActiveDividerCursor(restoreArrow: false)
} else {
reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToWindow")
}
window?.invalidateCursorRects(for: self)
onDidMoveToWindow?()
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow")
#endif
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToSuperview")
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview")
#endif
}
override func layout() {
super.layout()
reapplyHostedInspectorDividerIfNeeded(reason: "layout")
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
#endif
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
window?.invalidateCursorRects(for: self)
reapplyHostedInspectorDividerIfNeeded(reason: "setFrameOrigin")
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin")
#endif
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
window?.invalidateCursorRects(for: self)
reapplyHostedInspectorDividerIfNeeded(reason: "setFrameSize")
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize")
#endif
}
override func resetCursorRects() {
super.resetCursorRects()
guard let hostedInspectorHit = hostedInspectorDividerCandidate() else { return }
let clipped = hostedInspectorDividerHitRect(for: hostedInspectorHit).intersection(bounds)
guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { return }
addCursorRect(clipped, cursor: NSCursor.resizeLeftRight)
}
override func updateTrackingAreas() {
if let trackingArea {
removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [
.inVisibleRect,
.activeAlways,
.cursorUpdate,
.mouseMoved,
.mouseEnteredAndExited,
.enabledDuringMouseDrag,
]
let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
addTrackingArea(next)
trackingArea = next
super.updateTrackingAreas()
}
override func cursorUpdate(with event: NSEvent) {
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
}
override func mouseMoved(with event: NSEvent) {
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
}
override func mouseExited(with event: NSEvent) {
clearActiveDividerCursor(restoreArrow: true)
}
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
let hostedInspectorHit = hostedInspectorDividerHit(at: point)
updateDividerCursor(at: point, hostedInspectorHit: hostedInspectorHit)
let passThrough = shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: hostedInspectorHit)
if passThrough {
#if DEBUG
debugLogHitTest(stage: "hitTest.pass", point: point, passThrough: true, hitView: nil)
#endif
return nil
}
return super.hitTest(point)
if let hostedInspectorHit {
if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) {
#if DEBUG
debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit)
#endif
return nativeHit
}
#if DEBUG
debugLogHitTest(stage: "hitTest.hostedInspectorManual", point: point, passThrough: false, hitView: hostedInspectorHit.inspectorView)
#endif
return self
}
let hit = super.hitTest(point)
#if DEBUG
debugLogHitTest(stage: "hitTest.result", point: point, passThrough: false, hitView: hit)
#endif
return hit
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else {
super.mouseDown(with: event)
return
}
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
containerView: hostedInspectorHit.containerView,
pageView: hostedInspectorHit.pageView,
inspectorView: hostedInspectorHit.inspectorView,
initialWindowX: event.locationInWindow.x,
initialPageFrame: hostedInspectorHit.pageView.frame,
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
)
#if DEBUG
debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit)
#endif
}
override func mouseDragged(with event: NSEvent) {
guard let dragState = hostedInspectorDividerDrag else {
super.mouseDragged(with: event)
return
}
let containerBounds = dragState.containerView.bounds
let minimumInspectorWidth = min(
Self.minimumHostedInspectorWidth,
max(60, dragState.initialInspectorFrame.width)
)
let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX)
let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth)
let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX)
let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX))
let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX)
preferredHostedInspectorWidth = inspectorWidth
_ = applyHostedInspectorDividerWidth(
inspectorWidth,
to: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
),
reason: "drag"
)
#if DEBUG
debugLogHostedInspectorFrames(
stage: "drag.update",
point: convert(event.locationInWindow, from: nil),
hit: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
)
)
#endif
updateDividerCursor(
at: convert(event.locationInWindow, from: nil),
hostedInspectorHit: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
)
)
}
override func mouseUp(with event: NSEvent) {
let finalDragState = hostedInspectorDividerDrag
hostedInspectorDividerDrag = nil
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
scheduleHostedInspectorDividerReapply(reason: "dragEndAsync")
#if DEBUG
if let finalDragState {
let finalHit = HostedInspectorDividerHit(
containerView: finalDragState.containerView,
pageView: finalDragState.pageView,
inspectorView: finalDragState.inspectorView
)
debugLogHostedInspectorFrames(
stage: "drag.end",
point: convert(event.locationInWindow, from: nil),
hit: finalHit
)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.reapplyHostedInspectorDividerIfNeeded(reason: "drag.end.async")
self.debugLogHostedInspectorFrames(stage: "drag.end.async", hit: finalHit)
self.debugLogHostedInspectorLayoutIfNeeded(reason: "dragEndAsync")
}
}
#endif
super.mouseUp(with: event)
}
private func shouldPassThroughToSidebarResizer(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit? = nil
) -> Bool {
if hostedInspectorHit != nil {
return false
}
// Pass through a narrow leading-edge band so the shared sidebar divider
// handle can receive hover/click even when WKWebView is attached here.
// Keeping this deterministic avoids flicker from dynamic left-edge scans.
@ -3105,6 +3459,250 @@ struct WebViewRepresentable: NSViewRepresentable {
let hostRectInContent = contentView.convert(bounds, from: self)
return hostRectInContent.minX > 1
}
private func updateDividerCursor(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit? = nil
) {
let resolvedHostedInspectorHit = hostedInspectorHit ?? hostedInspectorDividerHit(at: point)
if shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: resolvedHostedInspectorHit) {
clearActiveDividerCursor(restoreArrow: false)
return
}
guard resolvedHostedInspectorHit != nil else {
clearActiveDividerCursor(restoreArrow: true)
return
}
activeDividerCursorKind = .vertical
NSCursor.resizeLeftRight.set()
}
private func clearActiveDividerCursor(restoreArrow: Bool) {
guard activeDividerCursorKind != nil else { return }
window?.invalidateCursorRects(for: self)
activeDividerCursorKind = nil
if restoreArrow {
NSCursor.arrow.set()
}
}
private func nativeHostedInspectorHit(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit
) -> NSView? {
guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil }
if nativeHit === hostedInspectorHit.pageView ||
nativeHit.isDescendant(of: hostedInspectorHit.pageView) {
return nil
}
if nativeHit === hostedInspectorHit.inspectorView ||
nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) {
return nativeHit
}
if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit),
!(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) {
return nativeHit
}
return nil
}
private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? {
guard let hit = hostedInspectorDividerCandidate(),
hostedInspectorDividerHitRect(for: hit).contains(point) else {
return nil
}
return hit
}
private func hostedInspectorDividerCandidate() -> HostedInspectorDividerHit? {
let inspectorCandidates = Self.visibleDescendants(in: self)
.filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) }
.sorted { lhs, rhs in
let lhsFrame = convert(lhs.bounds, from: lhs)
let rhsFrame = convert(rhs.bounds, from: rhs)
return lhsFrame.minX < rhsFrame.minX
}
var bestHit: HostedInspectorDividerHit?
var bestScore = -CGFloat.greatestFiniteMagnitude
for inspectorCandidate in inspectorCandidates {
guard let candidate = hostedInspectorDividerCandidate(startingAt: inspectorCandidate) else {
continue
}
let score = hostedInspectorDividerCandidateScore(candidate)
if score > bestScore {
bestScore = score
bestHit = candidate
}
}
return bestHit
}
private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect {
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY))
let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
return NSRect(
x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion,
y: minY,
width: Self.hostedInspectorDividerHitExpansion * 2,
height: max(0, maxY - minY)
)
}
private func hostedInspectorDividerCandidate(startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? {
var current: NSView? = inspectorLeaf
var bestHit: HostedInspectorDividerHit?
while let inspectorView = current, inspectorView !== self {
guard let containerView = inspectorView.superview else { break }
let pageCandidates = containerView.subviews.filter { candidate in
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false }
guard candidate !== inspectorView else { return false }
guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false }
return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8
}
if let pageView = pageCandidates.max(by: {
hostedInspectorPageCandidateScore($0, inspectorView: inspectorView)
< hostedInspectorPageCandidateScore($1, inspectorView: inspectorView)
}) {
bestHit = HostedInspectorDividerHit(
containerView: containerView,
pageView: pageView,
inspectorView: inspectorView
)
}
current = containerView
}
return bestHit
}
private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat {
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame)
let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX)
return (overlap * 1_000) + coverageWidth + pageFrame.width
}
private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat {
let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame)
let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX)
return (overlap * 1_000) + coverageWidth + pageView.frame.width
}
private func scheduleHostedInspectorDividerReapply(reason: String) {
guard preferredHostedInspectorWidth != nil else { return }
DispatchQueue.main.async { [weak self] in
self?.reapplyHostedInspectorDividerIfNeeded(reason: reason)
}
}
private func reapplyHostedInspectorDividerIfNeeded(reason: String) {
guard !isApplyingHostedInspectorLayout else { return }
guard let preferredWidth = preferredHostedInspectorWidth else { return }
guard let hit = hostedInspectorDividerCandidate() else {
#if DEBUG
if !hasLoggedMissingHostedInspectorCandidate {
hasLoggedMissingHostedInspectorCandidate = true
dlog(
"browser.panel.hostedInspector stage=\(reason).reapplyMissingCandidate " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth))"
)
}
#endif
return
}
#if DEBUG
hasLoggedMissingHostedInspectorCandidate = false
#endif
_ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
}
@discardableResult
private func applyHostedInspectorDividerWidth(
_ preferredWidth: CGFloat,
to hit: HostedInspectorDividerHit,
reason: String
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
let containerBounds = hit.containerView.bounds
let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX)
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth)
var pageFrame = hit.pageView.frame
pageFrame.size.width = max(0, dividerX - pageFrame.minX)
var inspectorFrame = hit.inspectorView.frame
inspectorFrame.origin.x = dividerX
inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
guard pageChanged || inspectorChanged else {
return (pageFrame, inspectorFrame)
}
isApplyingHostedInspectorLayout = true
CATransaction.begin()
CATransaction.setDisableActions(true)
hit.pageView.frame = pageFrame
hit.inspectorView.frame = inspectorFrame
CATransaction.commit()
isApplyingHostedInspectorLayout = false
hit.pageView.needsLayout = true
hit.inspectorView.needsLayout = true
hit.containerView.needsLayout = true
needsLayout = true
#if DEBUG
dlog(
"browser.panel.hostedInspector stage=\(reason).reapply " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
"container=\(Self.debugObjectID(hit.containerView)) " +
"pageFrame=\(Self.debugRect(pageFrame)) inspectorFrame=\(Self.debugRect(inspectorFrame))"
)
#endif
return (pageFrame, inspectorFrame)
}
private static func visibleDescendants(in root: NSView) -> [NSView] {
var descendants: [NSView] = []
var stack = Array(root.subviews.reversed())
while let view = stack.popLast() {
descendants.append(view)
stack.append(contentsOf: view.subviews.reversed())
}
return descendants
}
private static func isInspectorView(_ view: NSView) -> Bool {
String(describing: type(of: view)).contains("WKInspector")
}
private static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
view.frame.height > 1
}
private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.height > 1
}
private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
}
}
#if DEBUG
@ -3205,65 +3803,16 @@ struct WebViewRepresentable: NSViewRepresentable {
host.onGeometryChanged = nil
}
private static func removeSearchOverlay(from coordinator: Coordinator) {
coordinator.searchOverlayHostingView?.removeFromSuperview()
coordinator.searchOverlayHostingView = nil
}
private static func updateSearchOverlay(
panel: BrowserPanel,
coordinator: Coordinator,
containerView: NSView?
) {
// Layering contract: keep browser Cmd+F UI in the portal-hosted AppKit layer.
// SwiftUI panel overlays can be covered by portal-hosted WKWebView content.
guard let searchState = panel.searchState,
let containerView else {
removeSearchOverlay(from: coordinator)
return
private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
if anchorView.superview !== host {
anchorView.removeFromSuperview()
anchorView.frame = host.bounds
anchorView.translatesAutoresizingMaskIntoConstraints = true
anchorView.autoresizingMask = [.width, .height]
host.addSubview(anchorView)
} else if anchorView.frame != host.bounds {
anchorView.frame = host.bounds
}
let rootView = BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
onNext: { [weak panel] in
panel?.findNext()
},
onPrevious: { [weak panel] in
panel?.findPrevious()
},
onClose: { [weak panel] in
panel?.hideFind()
}
)
if let overlay = coordinator.searchOverlayHostingView {
overlay.rootView = rootView
if overlay.superview !== containerView {
overlay.removeFromSuperview()
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
])
} else if containerView.subviews.last !== overlay {
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
}
return
}
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
])
coordinator.searchOverlayHostingView = overlay
}
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
@ -3277,32 +3826,35 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil
let portalAnchorView = panel.portalAnchorView
Self.installPortalAnchorView(portalAnchorView, in: host)
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
guard let host, let webView, let coordinator else { return }
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
guard let host, let webView, let coordinator, let portalAnchorView else { return }
guard coordinator.attachGeneration == generation else { return }
Self.installPortalAnchorView(portalAnchorView, in: host)
guard host.window != nil else { return }
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
coordinator.lastPortalHostId = ObjectIdentifier(host)
if let panel = coordinator.panel {
Self.updateSearchOverlay(
panel: panel,
coordinator: coordinator,
containerView: webView.superview
)
}
}
host.onGeometryChanged = { [weak host, weak coordinator] in
guard let host, let coordinator else { return }
host.onGeometryChanged = { [weak host, weak coordinator, weak portalAnchorView] in
guard let host, let coordinator, let portalAnchorView else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
}
if !shouldAttachWebView {
@ -3319,20 +3871,21 @@ struct WebViewRepresentable: NSViewRepresentable {
previousVisible != shouldAttachWebView ||
previousZPriority != portalZPriority
if shouldBindNow {
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastPortalHostId = hostId
}
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
Self.updateSearchOverlay(
panel: panel,
coordinator: coordinator,
containerView: webView.superview
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: shouldAttachWebView ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
} else {
// Bind is deferred until host moves into a window. Keep the current
// portal entry's desired state in sync so stale callbacks cannot keep
@ -3342,17 +3895,21 @@ struct WebViewRepresentable: NSViewRepresentable {
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
Self.removeSearchOverlay(from: coordinator)
}
BrowserWindowPortalRegistry.updateDropZoneOverlay(
for: webView,
zone: shouldAttachWebView ? paneDropZone : nil
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: shouldAttachWebView ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(
for: webView,
context: paneDropContext
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
@ -3371,7 +3928,6 @@ struct WebViewRepresentable: NSViewRepresentable {
let webView = panel.webView
let coordinator = context.coordinator
if let previousWebView = coordinator.webView, previousWebView !== webView {
Self.removeSearchOverlay(from: coordinator)
BrowserWindowPortalRegistry.detach(webView: previousWebView)
coordinator.lastPortalHostId = nil
}
@ -3443,7 +3999,6 @@ struct WebViewRepresentable: NSViewRepresentable {
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachGeneration += 1
clearPortalCallbacks(for: nsView)
removeSearchOverlay(from: coordinator)
guard let webView = coordinator.webView else { return }
let panel = coordinator.panel
@ -3473,7 +4028,9 @@ struct WebViewRepresentable: NSViewRepresentable {
// rearrangement. Do not detach the portal-hosted WKWebView here; explicit detach
// still happens on real web view replacement and panel teardown.
BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(for: webView, height: 0)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil)
coordinator.lastPortalHostId = nil
}

View file

@ -1,3 +1,4 @@
import AppKit
import SwiftUI
import MarkdownUI
@ -30,9 +31,12 @@ struct MarkdownPanelView: View {
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.contentShape(Rectangle())
.onTapGesture {
onRequestPanelFocus()
.overlay {
if isVisibleInUI {
// Observe left-clicks without intercepting them so markdown text
// selection and link activation continue to use the native path.
MarkdownPointerObserver(onPointerDown: onRequestPanelFocus)
}
}
.onChange(of: panel.focusFlashToken) { _ in
triggerFocusFlashAnimation()
@ -283,3 +287,67 @@ struct MarkdownPanelView: View {
}
}
}
private struct MarkdownPointerObserver: NSViewRepresentable {
let onPointerDown: () -> Void
func makeNSView(context: Context) -> MarkdownPanelPointerObserverView {
let view = MarkdownPanelPointerObserverView()
view.onPointerDown = onPointerDown
return view
}
func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) {
nsView.onPointerDown = onPointerDown
}
}
final class MarkdownPanelPointerObserverView: NSView {
var onPointerDown: (() -> Void)?
private var eventMonitor: Any?
override var mouseDownCanMoveWindow: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
installEventMonitorIfNeeded()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
func shouldHandle(_ event: NSEvent) -> Bool {
guard event.type == .leftMouseDown,
let window,
event.window === window,
!isHiddenOrHasHiddenAncestor else { return false }
let point = convert(event.locationInWindow, from: nil)
return bounds.contains(point)
}
func handleEventIfNeeded(_ event: NSEvent) -> NSEvent {
guard shouldHandle(event) else { return event }
DispatchQueue.main.async { [weak self] in
self?.onPointerDown?()
}
return event
}
private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in
self?.handleEventIfNeeded(event) ?? event
}
}
}

View file

@ -558,16 +558,23 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
@MainActor
class TabManager: ObservableObject {
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
let branch: String?
let isDirty: Bool
}
/// 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
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
@Published var selectedTabId: UUID? {
didSet {
guard selectedTabId != oldValue else { return }
@ -599,7 +606,7 @@ class TabManager: ObservableObject {
self.focusSelectedTabPanel(previousTabId: previousTabId)
self.updateWindowTitleForSelectedTab()
if let selectedTabId = self.selectedTabId {
self.flashFocusedPanelIfUnreadAndActive(tabId: selectedTabId)
self.markFocusedPanelReadIfActive(tabId: selectedTabId)
}
#if DEBUG
let dtMs = self.debugWorkspaceSwitchStartTime > 0
@ -623,6 +630,12 @@ class TabManager: ObservableObject {
private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:]
private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
private let initialWorkspaceGitProbeQueue = DispatchQueue(
label: "com.cmux.initial-workspace-git-probe",
qos: .utility
)
private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:]
private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:]
// Recent tab history for back/forward navigation (like browser history)
private var tabHistory: [UUID] = []
@ -671,7 +684,7 @@ class TabManager: ObservableObject {
guard let self else { return }
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return }
flashPanelIfUnreadAndActive(tabId: tabId, panelId: surfaceId)
markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId)
}
})
@ -799,10 +812,12 @@ class TabManager: ObservableObject {
func addWorkspace(
workingDirectory overrideWorkingDirectory: String? = nil,
select: Bool = true,
eagerLoadTerminal: Bool = false,
placementOverride: NewWorkspacePlacement? = nil
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
@ -819,6 +834,18 @@ class TabManager: ObservableObject {
} else {
tabs.append(newWorkspace)
}
if let explicitWorkingDirectory,
let terminalPanel = newWorkspace.focusedTerminalPanel {
scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: newWorkspace.id,
panelId: terminalPanel.id,
directory: explicitWorkingDirectory
)
}
if eagerLoadTerminal {
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
}
if select {
selectedTabId = newWorkspace.id
NotificationCenter.default.post(
@ -837,9 +864,187 @@ class TabManager: ObservableObject {
return newWorkspace
}
private func scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: UUID,
panelId: UUID,
directory: String
) {
let normalizedDirectory = normalizeDirectory(directory)
let generation = UUID()
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation
#if DEBUG
dlog(
"workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)"
)
#endif
let delays = Self.initialWorkspaceGitProbeDelays
var timers: [DispatchSourceTimer] = []
for (index, delay) in delays.enumerated() {
let isLastAttempt = index == delays.count - 1
let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue)
timer.schedule(deadline: .now() + delay, repeating: .never)
timer.setEventHandler { [weak self] in
let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory)
Task { @MainActor [weak self] in
self?.applyInitialWorkspaceGitMetadataSnapshot(
snapshot,
generation: generation,
workspaceId: workspaceId,
panelId: panelId,
expectedDirectory: normalizedDirectory,
isLastAttempt: isLastAttempt
)
}
}
timers.append(timer)
timer.resume()
}
initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers
}
private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) {
guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else {
return
}
for timer in timers {
timer.setEventHandler {}
timer.cancel()
}
}
private func clearInitialWorkspaceGitProbe(workspaceId: UUID) {
initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId)
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
}
private func applyInitialWorkspaceGitMetadataSnapshot(
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
generation: UUID,
workspaceId: UUID,
panelId: UUID,
expectedDirectory: String,
isLastAttempt: Bool
) {
defer {
if isLastAttempt,
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
}
}
guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return }
guard let workspace = tabs.first(where: { $0.id == workspaceId }) else {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
return
}
guard workspace.panels[panelId] != nil else {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
return
}
let currentDirectory = normalizedWorkingDirectory(
workspace.panelDirectories[panelId] ?? workspace.currentDirectory
)
if let currentDirectory, currentDirectory != expectedDirectory {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
#if DEBUG
dlog(
"workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " +
"expected=\(expectedDirectory) current=\(currentDirectory)"
)
#endif
return
}
workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory)
let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch)
let nextBranch = snapshot.branch
if let nextBranch {
workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty)
} else {
workspace.clearPanelGitBranch(panelId: panelId)
}
if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
workspace.clearPanelPullRequest(panelId: panelId)
}
#if DEBUG
let branchLabel = snapshot.branch ?? "none"
dlog(
"workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)"
)
#endif
}
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
for directory: String
) -> InitialWorkspaceGitMetadataSnapshot {
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
guard let branch else {
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false)
}
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty)
}
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
let process = Process()
let stdout = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["git", "-C", directory] + arguments
process.standardOutput = stdout
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
return nil
}
// Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer.
let data = stdout.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
return nil
}
return String(data: data, encoding: .utf8)
}
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return }
}
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
}
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
pendingBackgroundWorkspaceLoadIds = pruned
}
// Keep addTab as convenience alias
@discardableResult
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace {
addWorkspace(select: select, eagerLoadTerminal: eagerLoadTerminal)
}
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
@ -1015,6 +1220,7 @@ class TabManager: ObservableObject {
guard tabs.count > 1 else { return }
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
unwireClosedBrowserTracking(for: workspace)
@ -1036,6 +1242,7 @@ class TabManager: ObservableObject {
@discardableResult
func detachWorkspace(tabId: UUID) -> Workspace? {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
clearInitialWorkspaceGitProbe(workspaceId: tabId)
let removed = tabs.remove(at: index)
unwireClosedBrowserTracking(for: removed)
@ -1596,16 +1803,16 @@ class TabManager: ObservableObject {
selectedTabId != pendingTabId
}
private func flashFocusedPanelIfUnreadAndActive(tabId: UUID) {
private func markFocusedPanelReadIfActive(tabId: UUID) {
let shouldSuppressFlash = suppressFocusFlash
suppressFocusFlash = false
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let panelId = focusedPanelId(for: tabId) else { return }
flashPanelIfUnreadAndActive(tabId: tabId, panelId: panelId)
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
}
private func flashPanelIfUnreadAndActive(tabId: UUID, panelId: UUID) {
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
guard selectedTabId == tabId else { return }
guard !suppressFocusFlash else { return }
guard AppFocusState.isAppActive() else { return }
@ -1614,6 +1821,7 @@ class TabManager: ObservableObject {
if let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
}
private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
@ -1739,6 +1947,7 @@ class TabManager: ObservableObject {
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetPanelId) else { return }
tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true)
notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId)
}
}
@ -3676,6 +3885,7 @@ extension Notification.Name {
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
static let feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested")
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")

View file

@ -775,6 +775,100 @@ class TerminalController {
)
}
nonisolated static func probeSocketCommand(
_ command: String,
at socketPath: String,
timeout: TimeInterval
) -> String? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
#if os(macOS)
var noSigPipe: Int32 = 1
_ = withUnsafePointer(to: &noSigPipe) { ptr in
setsockopt(
fd,
SOL_SOCKET,
SO_NOSIGPIPE,
ptr,
socklen_t(MemoryLayout<Int32>.size)
)
}
#endif
var addr = sockaddr_un()
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
addr.sun_family = sa_family_t(AF_UNIX)
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
let pathBytes = Array(socketPath.utf8CString)
guard pathBytes.count <= maxLen else { return nil }
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self)
memset(raw, 0, maxLen)
for index in 0..<pathBytes.count {
raw[index] = pathBytes[index]
}
}
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
let addrLen = socklen_t(pathOffset + pathBytes.count)
#if os(macOS)
addr.sun_len = UInt8(min(Int(addrLen), 255))
#endif
let connectResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
connect(fd, sockaddrPtr, addrLen)
}
}
guard connectResult == 0 else { return nil }
let payload = command + "\n"
let wroteAll = payload.withCString { cString in
var remaining = strlen(cString)
var pointer = UnsafeRawPointer(cString)
while remaining > 0 {
let written = write(fd, pointer, remaining)
if written <= 0 { return false }
remaining -= written
pointer = pointer.advanced(by: written)
}
return true
}
guard wroteAll else { return nil }
let deadline = Date().addingTimeInterval(timeout)
var buffer = [UInt8](repeating: 0, count: 4096)
var response = ""
while Date() < deadline {
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let ready = poll(&pollDescriptor, 1, 100)
if ready < 0 {
return nil
}
if ready == 0 {
continue
}
let count = read(fd, &buffer, buffer.count)
if count <= 0 {
break
}
if let chunk = String(bytes: buffer[0..<count], encoding: .utf8) {
response.append(chunk)
if let newlineIndex = response.firstIndex(of: "\n") {
return String(response[..<newlineIndex])
}
}
}
let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
nonisolated func stop() {
let (socketToClose, socketPathToUnlink) = withListenerState {
isRunning = false
@ -1376,6 +1470,9 @@ class TerminalController {
#if DEBUG
case "send_workspace":
return sendInputToWorkspace(args)
case "set_shortcut":
return setShortcut(args)
@ -2800,10 +2897,11 @@ class TerminalController {
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
v2MainSync {
let ws = tabManager.addWorkspace(workingDirectory: cwd, select: shouldFocus)
if !shouldFocus, let terminalPanel = ws.focusedTerminalPanel {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
let ws = tabManager.addWorkspace(
workingDirectory: cwd,
select: shouldFocus,
eagerLoadTerminal: !shouldFocus
)
newId = ws.id
}
#if DEBUG
@ -9523,6 +9621,7 @@ class TerminalController {
sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only)
terminal_drop_overlay_probe [deferred|direct] - Trigger focused terminal drop-overlay show path and report animation counts (test-only)
activate_app - Bring app + main window to front (test-only)
send_workspace <workspace_id> <text> - Send text to a workspace's selected terminal (test-only)
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
render_stats [id|idx] - Read terminal render stats (draw counters, test-only)
@ -10574,10 +10673,7 @@ class TerminalController {
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
DispatchQueue.main.sync {
let workspace = tabManager.addTab(select: focus)
if !focus, let terminalPanel = workspace.focusedTerminalPanel {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
let workspace = tabManager.addTab(select: focus, eagerLoadTerminal: !focus)
newTabId = workspace.id
}
#if DEBUG
@ -10760,7 +10856,13 @@ class TerminalController {
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
let tab: Tab?
if let tabId = UUID(uuidString: tabArg) {
tab = tabForSidebarMutation(id: tabId)
} else {
tab = resolveTab(from: tabArg, tabManager: tabManager)
}
guard let tab else {
result = "ERROR: Tab not found"
return
}
@ -11775,6 +11877,97 @@ class TerminalController {
return success ? "OK" : "ERROR: Failed to send input"
}
private func sendInputToWorkspace(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
guard parts.count == 2 else { return "ERROR: Usage: send_workspace <workspace_id> <text>" }
let workspaceArg = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
let text = parts[1]
guard let workspaceId = UUID(uuidString: workspaceArg) else {
return "ERROR: Invalid workspace ID"
}
var success = false
var error: String?
DispatchQueue.main.sync {
guard let targetManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId)
?? (tabManager.tabs.contains(where: { $0.id == workspaceId }) ? tabManager : nil) else {
error = "ERROR: Workspace not found"
return
}
guard let tab = targetManager.tabs.first(where: { $0.id == workspaceId }) else {
error = "ERROR: Workspace not found"
return
}
guard let terminalPanel = sendableWorkspaceTerminalPanel(in: tab) else {
error = "ERROR: No selected terminal in workspace"
return
}
let unescaped = text
.replacingOccurrences(of: "\\n", with: "\r")
.replacingOccurrences(of: "\\r", with: "\r")
.replacingOccurrences(of: "\\t", with: "\t")
// This DEBUG-only command is used by UI tests to enqueue shell work in an
// existing workspace. Return once the input is queued on main so a long
// payload does not hold the control-socket response open in CI.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if let surface = terminalPanel.surface.surface {
self.sendSocketText(unescaped, surface: surface)
} else {
terminalPanel.sendText(unescaped)
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
success = true
}
if let error { return error }
return success ? "OK" : "ERROR: Failed to send input"
}
private func sendableWorkspaceTerminalPanel(in workspace: Workspace) -> TerminalPanel? {
func selectedTerminalPanel(in paneId: PaneID) -> TerminalPanel? {
guard let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId),
let panelId = workspace.panelIdFromSurfaceId(selectedTab.id),
let terminalPanel = workspace.panels[panelId] as? TerminalPanel else {
return nil
}
return terminalPanel
}
func isSelectedTerminalPanel(_ terminalPanel: TerminalPanel) -> Bool {
guard let surfaceId = workspace.surfaceIdFromPanelId(terminalPanel.id) else {
return false
}
return workspace.bonsplitController.allPaneIds.contains { paneId in
workspace.bonsplitController.selectedTab(inPane: paneId)?.id == surfaceId
}
}
if let focusedPane = workspace.bonsplitController.focusedPaneId,
let terminalPanel = selectedTerminalPanel(in: focusedPane) {
return terminalPanel
}
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance(),
isSelectedTerminalPanel(rememberedTerminal) {
return rememberedTerminal
}
for paneId in workspace.bonsplitController.allPaneIds {
if let terminalPanel = selectedTerminalPanel(in: paneId) {
return terminalPanel
}
}
return nil
}
private func sendInputToSurface(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)

View file

@ -833,9 +833,16 @@ final class TerminalNotificationStore: ObservableObject {
let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId
let isFocusedPanel = isActiveTab && isFocusedSurface
let isAppFocused = AppFocusState.isAppFocused()
let suppressNativeDelivery = isAppFocused && isFocusedPanel
if isAppFocused && isFocusedPanel {
if !idsToClear.isEmpty {
notifications = updated
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
}
return
}
if WorkspaceAutoReorderSettings.isEnabled() && !suppressNativeDelivery {
if WorkspaceAutoReorderSettings.isEnabled() {
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
}
@ -855,11 +862,7 @@ final class TerminalNotificationStore: ObservableObject {
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
}
if suppressNativeDelivery {
Self.runNotificationCustomCommand(notification)
} else {
scheduleUserNotification(notification)
}
scheduleUserNotification(notification)
}
func markRead(id: UUID) {
@ -990,7 +993,10 @@ final class TerminalNotificationStore: ObservableObject {
guard let self, authorized else { return }
let content = UNMutableNotificationContent()
content.title = Self.notificationDisplayTitle(notification)
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
?? "cmux"
content.title = notification.title.isEmpty ? appName : notification.title
content.subtitle = notification.subtitle
content.body = notification.body
content.sound = NotificationSoundSettings.sound()
@ -1013,27 +1019,16 @@ final class TerminalNotificationStore: ObservableObject {
if let error {
NSLog("Failed to schedule notification: \(error)")
} else {
Self.runNotificationCustomCommand(notification)
NotificationSoundSettings.runCustomCommand(
title: content.title,
subtitle: content.subtitle,
body: content.body
)
}
}
}
}
nonisolated private static func notificationDisplayTitle(_ notification: TerminalNotification) -> String {
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
?? "cmux"
return notification.title.isEmpty ? appName : notification.title
}
nonisolated private static func runNotificationCustomCommand(_ notification: TerminalNotification) {
NotificationSoundSettings.runCustomCommand(
title: notificationDisplayTitle(notification),
subtitle: notification.subtitle,
body: notification.body
)
}
private func ensureAuthorization(
origin: AuthorizationRequestOrigin,
_ completion: @escaping (Bool) -> Void

View file

@ -1480,6 +1480,18 @@ final class Workspace: Identifiable, ObservableObject {
return surfaceKind(for: panel)
}
func requestBackgroundTerminalSurfaceStartIfNeeded() {
for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
func hasLoadedTerminalSurface() -> Bool {
let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel }
guard !terminalPanels.isEmpty else { return true }
return terminalPanels.contains { $0.surface.surface != nil }
}
func panelTitle(panelId: UUID) -> String? {
guard let panel = panels[panelId] else { return nil }
let fallback = panelTitles[panelId] ?? panel.displayTitle
@ -3268,6 +3280,19 @@ final class Workspace: Identifiable, ObservableObject {
}
}
/// Hide all browser portal views for this workspace.
/// Called before the workspace is unmounted so a portal-hosted WKWebView
/// cannot remain visible after this workspace stops being selected.
func hideAllBrowserPortalViews() {
for panel in panels.values {
guard let browser = panel as? BrowserPanel else { continue }
BrowserWindowPortalRegistry.hide(
webView: browser.webView,
source: "workspaceRetire"
)
}
}
// MARK: - Utility
/// Create a new terminal panel (used when replacing the last panel)

View file

@ -14,6 +14,8 @@ struct cmuxApp: App {
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data()
@ -268,15 +270,6 @@ struct cmuxApp: App {
}
#endif
CommandMenu(String(localized: "menu.updateLogs.title", defaultValue: "Update Logs")) {
Button(String(localized: "menu.updateLogs.copyUpdateLogs", defaultValue: "Copy Update Logs")) {
appDelegate.copyUpdateLogs(nil)
}
Button(String(localized: "menu.updateLogs.copyFocusLogs", defaultValue: "Copy Focus Logs")) {
appDelegate.copyFocusLogs(nil)
}
}
CommandMenu(String(localized: "menu.notifications.title", defaultValue: "Notifications")) {
let snapshot = notificationMenuSnapshot
@ -360,6 +353,10 @@ struct cmuxApp: App {
}
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
Toggle(
String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"),
isOn: $showSidebarDevBuildBanner
)
Divider()
@ -371,6 +368,15 @@ struct cmuxApp: App {
Divider()
Button(String(localized: "menu.updateLogs.copyUpdateLogs", defaultValue: "Copy Update Logs")) {
appDelegate.copyUpdateLogs(nil)
}
Button(String(localized: "menu.updateLogs.copyFocusLogs", defaultValue: "Copy Focus Logs")) {
appDelegate.copyFocusLogs(nil)
}
Divider()
Button("Trigger Sentry Test Crash") {
appDelegate.triggerSentryTestCrash(nil)
}
@ -1321,6 +1327,7 @@ private enum DebugWindowConfigSnapshot {
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue))
sidebarDevBuildBannerVisible=\(boolValue(defaults, key: DevBuildBannerDebugSettings.sidebarBannerVisibleKey, fallback: DevBuildBannerDebugSettings.defaultShowSidebarBanner))
shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX)))
@ -1775,7 +1782,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
fatalError("init(coder:) has not been implemented")
}
func show() {
func show(navigationTarget: SettingsNavigationTarget? = nil) {
guard let window else { return }
#if DEBUG
dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
@ -1785,12 +1792,39 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
window.center()
}
window.makeKeyAndOrderFront(nil)
if let navigationTarget {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
SettingsNavigationRequest.post(navigationTarget)
}
}
#if DEBUG
dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
#endif
}
}
enum SettingsNavigationTarget: String {
case keyboardShortcuts
}
enum SettingsNavigationRequest {
static let notificationName = Notification.Name("cmux.settings.navigate")
private static let targetKey = "target"
static func post(_ target: SettingsNavigationTarget) {
NotificationCenter.default.post(
name: notificationName,
object: nil,
userInfo: [targetKey: target.rawValue]
)
}
static func target(from notification: Notification) -> SettingsNavigationTarget? {
guard let rawValue = notification.userInfo?[targetKey] as? String else { return nil }
return SettingsNavigationTarget(rawValue: rawValue)
}
}
private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = SidebarDebugWindowController()
@ -1931,6 +1965,8 @@ private struct SidebarDebugView: View {
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@ -2154,6 +2190,7 @@ private struct SidebarDebugView: View {
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle)
sidebarDevBuildBannerVisible=\(showSidebarDevBuildBanner)
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
@ -3168,7 +3205,8 @@ struct SettingsView: View {
}
var body: some View {
ZStack(alignment: .top) {
ScrollViewReader { proxy in
ZStack(alignment: .top) {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"))
@ -3864,6 +3902,8 @@ struct SettingsView: View {
}
SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"))
.id(SettingsNavigationTarget.keyboardShortcuts)
.accessibilityIdentifier("SettingsKeyboardShortcutsSection")
SettingsCard {
SettingsCardRow(
String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"),
@ -3894,6 +3934,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundColor(.secondary)
.padding(.leading, 2)
.accessibilityIdentifier("ShortcutRecordingHint")
SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset"))
SettingsCard {
@ -4013,6 +4054,14 @@ struct SettingsView: View {
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
reloadWorkspaceTabColorSettings()
}
.onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in
guard let target = SettingsNavigationRequest.target(from: notification) else { return }
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(target, anchor: .top)
}
}
}
.confirmationDialog(
String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"),
isPresented: $showClearBrowserHistoryConfirmation,
@ -4061,6 +4110,7 @@ struct SettingsView: View {
} message: {
Text(notificationCustomSoundErrorAlertMessage)
}
}
}
private func relaunchApp() {

View file

@ -2071,9 +2071,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() {
var showFallbackSettingsWindowCallCount = 0
var activateApplicationCallCount = 0
var receivedNavigationTargets: [SettingsNavigationTarget?] = []
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTargets.append(navigationTarget)
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
@ -2083,14 +2085,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(showFallbackSettingsWindowCallCount, 1)
XCTAssertEqual(activateApplicationCallCount, 1)
XCTAssertEqual(receivedNavigationTargets, [nil])
}
func testPresentPreferencesWindowSupportsRepeatedCalls() {
var showFallbackSettingsWindowCallCount = 0
var activateApplicationCallCount = 0
var receivedNavigationTargets: [SettingsNavigationTarget?] = []
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTargets.append(navigationTarget)
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
@ -2099,7 +2104,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
)
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTargets.append(navigationTarget)
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
@ -2109,6 +2115,25 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(showFallbackSettingsWindowCallCount, 2)
XCTAssertEqual(activateApplicationCallCount, 2)
XCTAssertEqual(receivedNavigationTargets, [nil, nil])
}
func testPresentPreferencesWindowForwardsNavigationTarget() {
var receivedNavigationTarget: SettingsNavigationTarget?
var activateApplicationCallCount = 0
AppDelegate.presentPreferencesWindow(
navigationTarget: .keyboardShortcuts,
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTarget = navigationTarget
},
activateApplication: {
activateApplicationCallCount += 1
}
)
XCTAssertEqual(receivedNavigationTarget, .keyboardShortcuts)
XCTAssertEqual(activateApplicationCallCount, 1)
}
private func makeKeyDownEvent(

File diff suppressed because it is too large Load diff

View file

@ -275,6 +275,71 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
}
func testClickingBrowserDismissesCommandPaletteAndKeepsBrowserFocus() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
"Expected goto_split setup data to be written"
)
guard let setup = loadData() else {
XCTFail("Missing goto_split setup data")
return
}
guard let expectedBrowserPanelId = setup["browserPanelId"] else {
XCTFail("Missing browserPanelId in goto_split setup data")
return
}
guard let expectedTerminalPaneId = setup["terminalPaneId"] else {
XCTFail("Missing terminalPaneId in goto_split setup data")
return
}
// Move focus away from browser to terminal first so Cmd+R opens the rename overlay.
app.typeKey("h", modifierFlags: [.command, .control])
XCTAssertTrue(
waitForDataMatch(timeout: 5.0) { data in
data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId
},
"Expected Cmd+Ctrl+H to move focus to left pane (terminal)"
)
let renameField = app.textFields["CommandPaletteRenameField"].firstMatch
app.typeKey("r", modifierFlags: [.command])
XCTAssertTrue(
renameField.waitForExistence(timeout: 5.0),
"Expected Cmd+R to open the rename command palette while terminal is focused"
)
let browserPane = app.otherElements["BrowserPanelContent.\(expectedBrowserPanelId)"].firstMatch
XCTAssertTrue(browserPane.waitForExistence(timeout: 5.0), "Expected browser pane content for click target")
browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
XCTAssertTrue(
waitForNonExistence(renameField, timeout: 5.0),
"Expected clicking the browser pane to dismiss the command palette"
)
// Cmd+L behavior is context-aware:
// - If terminal is still focused: opens a new browser in that pane.
// - If the original browser took focus: focuses that existing browser's omnibar.
app.typeKey("l", modifierFlags: [.command])
XCTAssertTrue(
waitForDataMatch(timeout: 5.0) { data in
guard data["webViewFocusedAfterAddressBarFocus"] == "false" else { return false }
return data["webViewFocusedAfterAddressBarFocusPanelId"] == expectedBrowserPanelId
},
"Expected clicking browser content to dismiss the palette and keep focus on the existing browser pane"
)
}
func testCmdDSplitsRightWhenWebViewFocused() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
@ -644,6 +709,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
return false
}
private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func loadData() -> [String: String]? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else {
return nil

View file

@ -190,6 +190,159 @@ final class MultiWindowNotificationsUITests: XCTestCase {
XCTAssertFalse(after.contains(marker), "Expected typing to be blocked while empty notifications popover is open")
}
func testNotifyCLIDoesNotStealFocusAcrossWindows() throws {
let app = XCUIApplication()
app.launchArguments += ["-socketControlMode", "allowAll"]
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll"
app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
app.launchEnvironment["CMUX_UI_TEST_NOTIFY_SOURCE_TERMINAL_READY"] = "1"
app.launchEnvironment["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] = "1"
app.launchEnvironment["CMUX_TAG"] = launchTag
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for notify focus regression test. state=\(app.state.rawValue)"
)
XCTAssertTrue(
waitForDataMatch(timeout: 20.0) { data in
let tabId2 = data["tabId2"] ?? ""
let surfaceId2 = data["surfaceId2"] ?? ""
let socketReady = data["socketReady"] ?? ""
let sourceTerminalReady = data["sourceTerminalReady"] ?? ""
return !tabId2.isEmpty &&
!surfaceId2.isEmpty &&
!socketReady.isEmpty &&
socketReady != "pending" &&
!sourceTerminalReady.isEmpty &&
sourceTerminalReady != "pending"
},
"Expected multi-window notification setup data, socket readiness, and source terminal focus"
)
guard let setup = loadData() else {
XCTFail("Missing setup data")
return
}
guard let tabId2 = setup["tabId2"], !tabId2.isEmpty else {
XCTFail("Missing setup workspace id")
return
}
if let expectedSocketPath = setup["socketExpectedPath"], !expectedSocketPath.isEmpty {
socketPath = expectedSocketPath
}
if setup["socketReady"] != "1" {
XCTFail(
"Control socket unavailable in this test environment. expected=\(socketPath) " +
socketDiagnostics(from: setup)
)
return
}
guard setup["socketPingResponse"] == "PONG" else {
XCTFail(
"Control socket ping sanity check failed. path=\(socketPath) " +
socketDiagnostics(from: setup)
)
return
}
guard let surfaceId = setup["surfaceId2"], !surfaceId.isEmpty else {
XCTFail("Missing target surface id for workspace \(tabId2)")
return
}
guard setup["sourceTerminalReady"] == "1" else {
XCTFail(
"Expected source terminal to be focused before typing. " +
"failure=\(setup["sourceTerminalFocusFailure"] ?? "<unknown>")"
)
return
}
XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0))
let title = "focus-regression-\(UUID().uuidString.prefix(8))"
let commandResultStem = UUID().uuidString
let commandStatusPath = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).status")
.path
let commandStdoutPath = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).stdout")
.path
let commandStderrPath = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).stderr")
.path
let commandScriptPath = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).sh")
.path
defer {
try? FileManager.default.removeItem(atPath: commandStatusPath)
try? FileManager.default.removeItem(atPath: commandStdoutPath)
try? FileManager.default.removeItem(atPath: commandStderrPath)
try? FileManager.default.removeItem(atPath: commandScriptPath)
}
guard let bundledCLIPath = resolveCmuxCLIPaths(strategy: .bundledOnly).first else {
XCTFail("Failed to locate bundled cmux CLI for notify regression test")
return
}
let notifyScript = [
"#!/bin/sh",
"sleep 1",
"rm -f \(shellSingleQuote(commandStatusPath)) \(shellSingleQuote(commandStdoutPath)) \(shellSingleQuote(commandStderrPath))",
"\(shellSingleQuote(bundledCLIPath)) --socket \(shellSingleQuote(socketPath)) notify --workspace \(shellSingleQuote(tabId2)) --surface \(shellSingleQuote(surfaceId)) --title \(shellSingleQuote(title)) --subtitle \(shellSingleQuote("ui-test")) --body \(shellSingleQuote("focus-regression")) >\(shellSingleQuote(commandStdoutPath)) 2>\(shellSingleQuote(commandStderrPath))",
"printf '%s' $? >\(shellSingleQuote(commandStatusPath))"
].joined(separator: "\n")
do {
try notifyScript.write(toFile: commandScriptPath, atomically: true, encoding: .utf8)
} catch {
XCTFail(
"Failed to write delayed bundled `cmux notify` script. " +
"path=\(commandScriptPath) error=\(error)"
)
return
}
app.typeText("sh \(commandScriptPath)")
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
let finder = XCUIApplication(bundleIdentifier: "com.apple.finder")
finder.activate()
XCTAssertTrue(
waitForAppToLeaveForeground(app, timeout: 8.0),
"Expected cmux to move to background before delayed notify command runs. state=\(app.state.rawValue)"
)
XCTAssertTrue(
waitForCommandCompletionWhileBackgrounded(
statusPath: commandStatusPath,
app: app,
timeout: 15.0
),
"Expected delayed bundled `cmux notify` command to finish without foregrounding cmux. state=\(app.state.rawValue)"
)
let notifyExitStatus = readTrimmedFile(atPath: commandStatusPath) ?? "<missing>"
let notifyStdout = readTrimmedFile(atPath: commandStdoutPath) ?? ""
let notifyStderr = readTrimmedFile(atPath: commandStderrPath) ?? ""
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
XCTAssertFalse(
app.state == .runningForeground,
"Expected cmux to remain in background after bundled `cmux notify`. state=\(app.state.rawValue) stderr=\(notifyStderr)"
)
guard notifyExitStatus == "0" else {
XCTFail(
"Expected bundled `cmux notify` launched from the in-app shell to succeed. " +
"status=\(notifyExitStatus) stdout=\(notifyStdout) stderr=\(notifyStderr)"
)
return
}
XCTAssertTrue(notifyStdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyStdout) stderr=\(notifyStderr)")
}
private func clickNotificationPopoverRowAndWaitForFocusChange(
button: XCUIElement,
app: XCUIApplication,
@ -274,6 +427,20 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return false
}
private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadData(), predicate(data) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadData(), predicate(data) {
return true
}
return false
}
private func waitForSocketPong(timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
var lastResponse: String?
@ -287,33 +454,549 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return socketCommand("ping") ?? lastResponse
}
private func resolveSocketPath(timeout: TimeInterval) -> String? {
private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
for candidate in expectedSocketCandidates() {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate) {
return candidate
}
if socketCommand("is_terminal_focused \(surfaceId)") == "true" {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
for candidate in expectedSocketCandidates() {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate) {
return socketCommand("is_terminal_focused \(surfaceId)") == "true"
}
private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) {
let deadline = Date().addingTimeInterval(timeout)
var lastStdout: String?
var lastStderr: String?
while Date() < deadline {
let result = runCmuxCommand(
socketPath: socketPath,
arguments: ["ping"],
responseTimeoutSeconds: 2.0
)
let stdout = result.stdout.isEmpty ? nil : result.stdout
let stderr = result.stderr.isEmpty ? nil : result.stderr
if let stdout {
lastStdout = stdout
}
if let stderr {
lastStderr = stderr
}
if result.terminationStatus == 0, stdout == "PONG" {
return ("PONG", stderr)
}
if isSocketPermissionFailure(stderr),
waitForSocketPong(timeout: 0.5) == "PONG" {
return ("PONG", stderr)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
let result = runCmuxCommand(
socketPath: socketPath,
arguments: ["ping"],
responseTimeoutSeconds: 2.0
)
let stdout = result.stdout.isEmpty ? nil : result.stdout
let stderr = result.stderr.isEmpty ? nil : result.stderr
if isSocketPermissionFailure(stderr),
waitForSocketPong(timeout: 0.5) == "PONG" {
return ("PONG", stderr)
}
return (stdout ?? lastStdout, stderr ?? lastStderr)
}
private func waitForCommandCompletionWhileBackgrounded(
statusPath: String,
app: XCUIApplication,
timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
var sawCompletion = false
while Date() < deadline {
if app.state == .runningForeground {
return false
}
if FileManager.default.fileExists(atPath: statusPath) {
sawCompletion = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else {
return false
}
let postCompletionDeadline = Date().addingTimeInterval(0.75)
while Date() < postCompletionDeadline {
if app.state == .runningForeground {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return app.state != .runningForeground
}
private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.state != .runningForeground {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return app.state != .runningForeground
}
private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? {
guard let response = socketCommand("list_surfaces \(workspaceId)"),
!response.isEmpty,
!response.hasPrefix("ERROR"),
response != "No surfaces" else {
return nil
}
for line in response.split(separator: "\n", omittingEmptySubsequences: true) {
let parts = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2 else { continue }
let candidate = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
if UUID(uuidString: candidate) != nil {
return candidate
}
}
return nil
}
private func expectedSocketCandidates() -> [String] {
private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) {
return surfaceId
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return firstSurfaceId(forWorkspaceId: workspaceId)
}
private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) {
return surfaceId
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
}
private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? {
guard let paneId = firstPaneIdViaCLI(forWorkspaceId: workspaceId) else {
return firstSurfaceId(forWorkspaceId: workspaceId)
}
let result = runCmuxCommand(
socketPath: socketPath,
arguments: [
"list-pane-surfaces",
"--workspace",
workspaceId,
"--pane",
paneId,
"--id-format",
"uuids"
],
responseTimeoutSeconds: 3.0
)
guard result.terminationStatus == 0 else {
if isSocketPermissionFailure(result.stderr) {
return firstSurfaceId(forWorkspaceId: workspaceId)
}
return nil
}
return firstHandle(in: result.stdout)
}
private func firstPaneIdViaCLI(forWorkspaceId workspaceId: String) -> String? {
let result = runCmuxCommand(
socketPath: socketPath,
arguments: [
"list-panes",
"--workspace",
workspaceId,
"--id-format",
"uuids"
],
responseTimeoutSeconds: 3.0
)
guard result.terminationStatus == 0 else {
if isSocketPermissionFailure(result.stderr) {
return nil
}
return nil
}
return firstHandle(in: result.stdout)
}
private func firstHandle(in output: String) -> String? {
for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) {
var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty, !line.hasPrefix("No ") else { continue }
if line.hasPrefix("* ") || line.hasPrefix(" ") {
line = String(line.dropFirst(2))
}
guard let token = line.split(whereSeparator: \.isWhitespace).first else { continue }
return String(token)
}
return nil
}
private func runCmuxNotify(
socketPath: String,
workspaceId: String,
surfaceId: String,
title: String
) -> (terminationStatus: Int32, stdout: String, stderr: String) {
runCmuxCommand(
socketPath: socketPath,
arguments: [
"notify",
"--workspace",
workspaceId,
"--surface",
surfaceId,
"--title",
title,
"--subtitle",
"ui-test",
"--body",
"focus-regression"
],
responseTimeoutSeconds: 4.0,
cliStrategy: .bundledOnly
)
}
private func runCmuxCommand(
socketPath: String,
arguments: [String],
responseTimeoutSeconds: Double = 3.0,
cliStrategy: CmuxCLIStrategy = .any
) -> (terminationStatus: Int32, stdout: String, stderr: String) {
var args = ["--socket", socketPath]
args.append(contentsOf: arguments)
var environment = ProcessInfo.processInfo.environment
environment["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = String(responseTimeoutSeconds)
let cliPaths = resolveCmuxCLIPaths(strategy: cliStrategy)
if cliPaths.isEmpty, cliStrategy == .bundledOnly {
return (
terminationStatus: -1,
stdout: "",
stderr: "Failed to locate bundled cmux CLI"
)
}
var lastPermissionFailure: (terminationStatus: Int32, stdout: String, stderr: String)?
for cliPath in cliPaths {
let result = executeCmuxCommand(
executablePath: cliPath,
arguments: args,
environment: environment
)
if result.terminationStatus == 0 {
return result
}
if result.stderr.localizedCaseInsensitiveContains("operation not permitted") {
lastPermissionFailure = result
continue
}
return result
}
if cliStrategy == .bundledOnly {
return lastPermissionFailure ?? (
terminationStatus: -1,
stdout: "",
stderr: "Bundled cmux CLI command failed without an executable path"
)
}
let fallbackArgs = ["cmux"] + args
let fallbackResult = executeCmuxCommand(
executablePath: "/usr/bin/env",
arguments: fallbackArgs,
environment: environment
)
if fallbackResult.terminationStatus == 0 || lastPermissionFailure == nil {
return fallbackResult
}
return lastPermissionFailure ?? fallbackResult
}
private enum CmuxCLIStrategy: Equatable {
case any
case bundledOnly
}
private func socketDiagnostics(from data: [String: String]) -> String {
let pingResponse = data["socketPingResponse"].flatMap { $0.isEmpty ? nil : $0 } ?? "<nil>"
return "mode=\(data["socketMode"] ?? "") running=\(data["socketIsRunning"] ?? "") " +
"acceptLoopAlive=\(data["socketAcceptLoopAlive"] ?? "") pathMatches=\(data["socketPathMatches"] ?? "") " +
"pathExists=\(data["socketPathExists"] ?? "") ping=\(pingResponse) " +
"signals=\(data["socketFailureSignals"] ?? "")"
}
private func resolveCmuxCLIPaths(strategy: CmuxCLIStrategy) -> [String] {
let fileManager = FileManager.default
let env = ProcessInfo.processInfo.environment
var candidates: [String] = []
var productDirectories: [String] = []
if strategy == .any {
for key in ["CMUX_UI_TEST_CLI_PATH", "CMUXTERM_CLI"] {
if let value = env[key], !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
candidates.append(value)
}
}
}
if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty {
productDirectories.append(builtProductsDir)
}
if let hostPath = env["TEST_HOST"], !hostPath.isEmpty {
let hostURL = URL(fileURLWithPath: hostPath)
let productsDir = hostURL
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.path
productDirectories.append(productsDir)
}
productDirectories.append(contentsOf: inferredBuildProductsDirectories())
for productsDir in uniquePaths(productDirectories) {
appendCLIPathCandidates(fromProductsDirectory: productsDir, strategy: strategy, to: &candidates)
}
candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux DEV.app/Contents/Resources/bin/cmux")
candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux.app/Contents/Resources/bin/cmux")
if strategy == .any {
candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux")
}
var resolvedPaths: [String] = []
for path in uniquePaths(candidates) {
guard fileManager.isExecutableFile(atPath: path) else { continue }
resolvedPaths.append(URL(fileURLWithPath: path).resolvingSymlinksInPath().path)
}
return uniquePaths(resolvedPaths)
}
private func inferredBuildProductsDirectories() -> [String] {
let bundleURLs = [
Bundle.main.bundleURL,
Bundle(for: Self.self).bundleURL,
]
return bundleURLs.compactMap { bundleURL in
let standardizedPath = bundleURL.standardizedFileURL.path
let components = standardizedPath.split(separator: "/")
guard let productsIndex = components.firstIndex(of: "Products"),
productsIndex + 1 < components.count else {
return nil
}
let prefixComponents = components.prefix(productsIndex + 2)
return "/" + prefixComponents.joined(separator: "/")
}
}
private func appendCLIPathCandidates(
fromProductsDirectory productsDir: String,
strategy: CmuxCLIStrategy,
to candidates: inout [String]
) {
candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux")
candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux")
if strategy == .any {
candidates.append("\(productsDir)/cmux")
}
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: productsDir) else {
return
}
for entry in entries.sorted() where entry.hasSuffix(".app") {
let cliPath = URL(fileURLWithPath: productsDir)
.appendingPathComponent(entry)
.appendingPathComponent("Contents/Resources/bin/cmux")
.path
candidates.append(cliPath)
}
if strategy == .any {
for entry in entries.sorted() where entry == "cmux" {
let cliPath = URL(fileURLWithPath: productsDir)
.appendingPathComponent(entry)
.path
candidates.append(cliPath)
}
}
}
private func executeCmuxCommand(
executablePath: String,
arguments: [String],
environment: [String: String]
) -> (terminationStatus: Int32, stdout: String, stderr: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.environment = environment
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
process.waitUntilExit()
} catch {
return (
terminationStatus: -1,
stdout: "",
stderr: "Failed to run cmux command: \(error.localizedDescription) (cliPath=\(executablePath))"
)
}
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let stdout = String(data: stdoutData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let rawStderr = String(data: stderrData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let stderr = rawStderr.isEmpty ? "" : "\(rawStderr) (cliPath=\(executablePath))"
return (process.terminationStatus, stdout, stderr)
}
private func isSocketPermissionFailure(_ stderr: String?) -> Bool {
guard let stderr, !stderr.isEmpty else { return false }
return stderr.localizedCaseInsensitiveContains("failed to connect to socket") &&
stderr.localizedCaseInsensitiveContains("operation not permitted")
}
private func uniquePaths(_ paths: [String]) -> [String] {
var unique: [String] = []
var seen = Set<String>()
for path in paths {
if seen.insert(path).inserted {
unique.append(path)
}
}
return unique
}
private func resolveSocketPath(timeout: TimeInterval, requiredWorkspaceId: String? = nil) -> String? {
let primaryCandidates = expectedSocketCandidates(includeGlobalFallback: false)
let fallbackCandidates: [String]
if let requiredWorkspaceId, !requiredWorkspaceId.isEmpty {
fallbackCandidates = expectedSocketCandidates(includeGlobalFallback: true)
.filter { !primaryCandidates.contains($0) }
} else {
fallbackCandidates = []
}
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
for candidate in primaryCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
// Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds,
// prefer it even before workspace contents are fully initialized.
if socketRespondsToPing(at: candidate) {
return candidate
}
}
for candidate in fallbackCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate),
socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) {
return candidate
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
for candidate in primaryCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate) {
return candidate
}
}
for candidate in fallbackCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate),
socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) {
return candidate
}
}
return nil
}
private func expectedSocketCandidates(includeGlobalFallback: Bool) -> [String] {
var candidates = [socketPath]
let taggedDebugSocket = "/tmp/cmux-debug-\(launchTag).sock"
if taggedDebugSocket != socketPath {
if !taggedDebugSocket.isEmpty {
candidates.append(taggedDebugSocket)
}
return candidates
if includeGlobalFallback {
candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12))
candidates.append("/tmp/cmux-debug.sock")
candidates.append("/tmp/cmux.sock")
}
var unique: [String] = []
var seen = Set<String>()
for candidate in candidates {
if seen.insert(candidate).inserted {
unique.append(candidate)
}
}
return unique
}
private func socketMatchesRequiredWorkspace(_ candidatePath: String, workspaceId: String?) -> Bool {
guard let workspaceId, !workspaceId.isEmpty else { return true }
let originalPath = socketPath
socketPath = candidatePath
defer { socketPath = originalPath }
guard let response = socketCommand("list_surfaces \(workspaceId)"),
!response.isEmpty,
!response.hasPrefix("ERROR"),
response != "No surfaces" else {
return false
}
return true
}
private func discoverTmpSocketCandidates(limit: Int) -> [String] {
let tmpPath = "/tmp"
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else {
return []
}
let matches = entries.filter { $0.hasPrefix("cmux") && $0.hasSuffix(".sock") }
let sorted = matches.compactMap { entry -> (path: String, mtime: Date)? in
let fullPath = (tmpPath as NSString).appendingPathComponent(entry)
guard let attrs = try? FileManager.default.attributesOfItem(atPath: fullPath) else {
return nil
}
let mtime = (attrs[.modificationDate] as? Date) ?? .distantPast
return (fullPath, mtime)
}
.sorted { $0.mtime > $1.mtime }
return Array(sorted.prefix(limit)).map(\.path)
}
private func socketRespondsToPing(at path: String) -> Bool {
@ -323,20 +1006,21 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return socketCommand("ping") == "PONG"
}
private func socketCommand(_ cmd: String) -> String? {
if let response = ControlSocketClient(path: socketPath).sendLine(cmd) {
private func socketCommand(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? {
if let response = ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(cmd) {
return response
}
return socketCommandViaNetcat(cmd)
return socketCommandViaNetcat(cmd, responseTimeout: responseTimeout)
}
private func socketCommandViaNetcat(_ cmd: String) -> String? {
private func socketCommandViaNetcat(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? {
let nc = "/usr/bin/nc"
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w 2 2>/dev/null"
let timeoutSeconds = max(1, Int(ceil(responseTimeout)))
let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w \(timeoutSeconds) 2>/dev/null"
proc.arguments = ["-lc", script]
let outPipe = Pipe()
@ -364,11 +1048,21 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
private func readTrimmedFile(atPath path: String) -> String? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private final class ControlSocketClient {
private let path: String
private let responseTimeout: TimeInterval
init(path: String) {
init(path: String, responseTimeout: TimeInterval = 2.0) {
self.path = path
self.responseTimeout = responseTimeout
}
func sendLine(_ line: String) -> String? {
@ -431,9 +1125,18 @@ final class MultiWindowNotificationsUITests: XCTestCase {
}
guard wrote else { return nil }
let deadline = Date().addingTimeInterval(responseTimeout)
var buf = [UInt8](repeating: 0, count: 4096)
var accum = ""
while true {
while Date() < deadline {
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let ready = poll(&pollDescriptor, 1, 100)
if ready < 0 {
return nil
}
if ready == 0 {
continue
}
let n = read(fd, &buf, buf.count)
if n <= 0 { break }
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {

View file

@ -0,0 +1,296 @@
import XCTest
private func sidebarHelpPollUntil(
timeout: TimeInterval,
pollInterval: TimeInterval = 0.05,
condition: () -> Bool
) -> Bool {
let start = ProcessInfo.processInfo.systemUptime
while true {
if condition() {
return true
}
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
}
final class SidebarHelpMenuUITests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
}
func testHelpMenuOpensKeyboardShortcutsSection() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
launchAndActivate(app)
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
let helpButton = requireElement(
candidates: helpButtonCandidates(in: app),
timeout: 6.0,
description: "sidebar help button"
)
helpButton.click()
let keyboardShortcutsItem = requireElement(
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionKeyboardShortcuts", title: "Keyboard Shortcuts"),
timeout: 3.0,
description: "Keyboard Shortcuts help menu item"
)
keyboardShortcutsItem.click()
XCTAssertTrue(app.staticTexts["ShortcutRecordingHint"].waitForExistence(timeout: 6.0))
}
func testHelpMenuCheckForUpdatesTriggersSidebarUpdatePill() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml"
app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = "available"
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1"
launchAndActivate(app)
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
let helpButton = requireElement(
candidates: helpButtonCandidates(in: app),
timeout: 6.0,
description: "sidebar help button"
)
helpButton.click()
let checkForUpdatesItem = requireElement(
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionCheckForUpdates", title: "Check for Updates"),
timeout: 3.0,
description: "Check for Updates help menu item"
)
checkForUpdatesItem.click()
let updatePill = app.buttons["UpdatePill"]
XCTAssertTrue(updatePill.waitForExistence(timeout: 6.0))
XCTAssertEqual(updatePill.label, "Update Available: 9.9.9")
}
func testHelpMenuSendFeedbackOpensComposerSheet() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
launchAndActivate(app)
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
let helpButton = requireElement(
candidates: helpButtonCandidates(in: app),
timeout: 6.0,
description: "sidebar help button"
)
helpButton.click()
let sendFeedbackItem = requireElement(
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionSendFeedback", title: "Send Feedback"),
timeout: 3.0,
description: "Send Feedback help menu item"
)
sendFeedbackItem.click()
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
XCTAssertTrue(
firstExistingElement(
candidates: [
app.textFields["SidebarFeedbackEmailField"],
app.textFields["Your Email"],
],
timeout: 2.0
) != nil
)
XCTAssertTrue(
firstExistingElement(
candidates: [
app.buttons["SidebarFeedbackAttachButton"],
app.buttons["Attach Images"],
],
timeout: 2.0
) != nil
)
XCTAssertTrue(
firstExistingElement(
candidates: [
app.buttons["SidebarFeedbackSendButton"],
app.buttons["Send"],
],
timeout: 2.0
) != nil
)
XCTAssertTrue(
app.staticTexts[
"A human will read this! You can also reach us at founders@manaflow.com."
].waitForExistence(timeout: 2.0)
)
let messageEditor = requireElement(
candidates: [
app.textViews["SidebarFeedbackMessageEditor"],
app.scrollViews["SidebarFeedbackMessageEditor"],
app.otherElements["SidebarFeedbackMessageEditor"],
app.textViews["Message"],
],
timeout: 2.0,
description: "feedback message editor"
)
messageEditor.click()
app.typeText("hello")
XCTAssertTrue(app.staticTexts["5/4000"].waitForExistence(timeout: 2.0))
}
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
sidebarHelpPollUntil(timeout: timeout) {
app.windows.count >= count
}
}
private func helpButtonCandidates(in app: XCUIApplication) -> [XCUIElement] {
let sidebar = app.otherElements["Sidebar"]
return [
app.buttons["SidebarHelpMenuButton"],
app.buttons["Help"],
sidebar.buttons["SidebarHelpMenuButton"],
sidebar.buttons["Help"],
]
}
private func helpMenuItemCandidates(
in app: XCUIApplication,
identifier: String,
title: String
) -> [XCUIElement] {
[
app.buttons[identifier],
app.buttons[title],
]
}
private func firstExistingElement(
candidates: [XCUIElement],
timeout: TimeInterval
) -> XCUIElement? {
var match: XCUIElement?
let found = sidebarHelpPollUntil(timeout: timeout) {
for candidate in candidates where candidate.exists {
match = candidate
return true
}
return false
}
return found ? match : nil
}
private func requireElement(
candidates: [XCUIElement],
timeout: TimeInterval,
description: String
) -> XCUIElement {
guard let element = firstExistingElement(candidates: candidates, timeout: timeout) else {
XCTFail("Expected \(description) to exist")
return candidates[0]
}
return element
}
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
app.launch()
let activated = sidebarHelpPollUntil(timeout: activateTimeout) {
guard app.state != .runningForeground else {
return true
}
app.activate()
return app.state == .runningForeground
}
if !activated {
app.activate()
}
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 2.0) { app.state == .runningForeground },
"App did not reach runningForeground before UI interactions"
)
}
}
final class FeedbackComposerShortcutUITests: XCTestCase {
override func setUp() {
super.setUp()
continueAfterFailure = false
}
func testCmdOptionFOpensFeedbackComposer() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launch()
app.activate()
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 6.0) {
app.windows.count >= 1
}
)
app.typeKey("f", modifierFlags: [.command, .option])
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
XCTAssertTrue(
app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0)
|| app.textFields["Your Email"].waitForExistence(timeout: 2.0)
)
}
func testCmdOptionFWorksWithHiddenSidebar() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launch()
app.activate()
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 6.0) {
app.windows.count >= 1
}
)
app.typeKey("b", modifierFlags: [.command])
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 3.0) {
!app.buttons["SidebarHelpMenuButton"].exists && !app.buttons["Help"].exists
}
)
app.typeKey("f", modifierFlags: [.command, .option])
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
}
func testCmdOptionFWorksFromSettingsWindow() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1"
app.launch()
app.activate()
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 6.0) {
app.windows.count >= 2
}
)
app.typeKey("f", modifierFlags: [.command, .option])
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
XCTAssertTrue(
app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0)
|| app.textFields["Your Email"].waitForExistence(timeout: 2.0)
)
}
}

View file

@ -318,14 +318,13 @@ OPEN_CLEAN_ENV=(
if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides.
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH"
elif [[ -n "${TAG_SLUG:-}" ]]; then
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH"
else
echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true
"${OPEN_CLEAN_ENV[@]}" open "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" open -g "$APP_PATH"
fi
osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true
# Safety: ensure only one instance is running.
sleep 0.2

View file

@ -17,5 +17,4 @@ if [[ -z "${APP_PATH}" ]]; then
fi
# Dev shells (including CI/Codex) often force-disable paging by exporting these.
# Don't leak that into cmux, otherwise `git diff` won't page even with PAGER=less.
env -u GIT_PAGER -u GH_PAGER open "$APP_PATH"
osascript -e 'tell application "cmux" to activate' || true
env -u GIT_PAGER -u GH_PAGER open -g "$APP_PATH"

View file

@ -251,8 +251,7 @@ OPEN_CLEAN_ENV=(
# Always inject staging socket paths via env to ensure they take effect
# (LSEnvironment requires app restart to pick up plist changes).
"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH"
osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true
"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open -g "$APP_PATH"
# Safety: ensure only one instance is running.
sleep 0.2

View file

@ -10,19 +10,21 @@ Use this skill for browser tasks inside cmux webviews.
## Core Workflow
1. Open or target a browser surface.
2. Snapshot (`--interactive`) to get fresh element refs.
3. Act with refs (`click`, `fill`, `type`, `select`, `press`).
4. Wait for state changes.
5. Re-snapshot after DOM/navigation changes.
2. Verify navigation with `get url` before waiting or snapshotting.
3. Snapshot (`--interactive`) to get fresh element refs.
4. Act with refs (`click`, `fill`, `type`, `select`, `press`).
5. Wait for state changes.
6. Re-snapshot after DOM/navigation changes.
```bash
cmux browser open https://example.com --json
cmux --json browser open https://example.com
# use returned surface ref, for example: surface:7
cmux browser surface:7 get url
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
cmux browser surface:7 snapshot --interactive
cmux browser surface:7 fill e1 "hello"
cmux browser surface:7 click e2 --snapshot-after --json
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
cmux --json browser surface:7 click e2 --snapshot-after
cmux browser surface:7 snapshot --interactive
```
@ -58,11 +60,13 @@ cmux browser <surface> wait --function "document.readyState === 'complete'" --ti
### Form Submit
```bash
cmux browser open https://example.com/signup --json
cmux --json browser open https://example.com/signup
cmux browser surface:7 get url
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
cmux browser surface:7 snapshot --interactive
cmux browser surface:7 fill e1 "Jane Doe"
cmux browser surface:7 fill e2 "jane@example.com"
cmux browser surface:7 click e3 --snapshot-after --json
cmux --json browser surface:7 click e3 --snapshot-after
cmux browser surface:7 wait --url-contains "/welcome" --timeout-ms 15000
cmux browser surface:7 snapshot --interactive
```
@ -77,13 +81,16 @@ cmux browser surface:7 get value e11 --json
### Stable Agent Loop (Recommended)
```bash
# snapshot -> action -> wait -> snapshot
cmux browser surface:7 snapshot --interactive
cmux browser surface:7 click e5 --snapshot-after --json
# navigate -> verify -> wait -> snapshot -> action -> snapshot
cmux browser surface:7 get url
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
cmux browser surface:7 snapshot --interactive
cmux --json browser surface:7 click e5 --snapshot-after
cmux browser surface:7 snapshot --interactive
```
If `get url` is empty or `about:blank`, navigate first instead of waiting on load state.
## Deep-Dive References
| Reference | When to Use |
@ -114,3 +121,21 @@ These commands currently return `not_supported` because they rely on Chrome/CDP-
- low-level raw input injection
Use supported high-level commands (`click`, `fill`, `press`, `scroll`, `wait`, `snapshot`) instead.
## Troubleshooting
### `js_error` on `snapshot --interactive` or `eval`
Some complex pages can reject or break the JavaScript used for rich snapshots and ad-hoc evaluation.
Recovery steps:
```bash
cmux browser surface:7 get url
cmux browser surface:7 get text body
cmux browser surface:7 get html body
```
- Use `get url` first so you know whether the page actually navigated.
- Fall back to `get text body` or `get html body` when `snapshot --interactive` or `eval` returns `js_error`.
- If the page is still failing, navigate to a simpler intermediate page, then retry the task from there.

View file

@ -11,7 +11,7 @@ This maps common `agent-browser` usage to `cmux browser` usage.
- `agent-browser fill <ref> <text>` -> `cmux browser <surface> fill <ref> <text>`
- `agent-browser type <ref> <text>` -> `cmux browser <surface> type <ref> <text>`
- `agent-browser select <ref> <value>` -> `cmux browser <surface> select <ref> <value>`
- `agent-browser get text <ref>` -> `cmux browser <surface> get text <ref>`
- `agent-browser get text <ref>` -> `cmux browser <surface> get text <ref-or-selector>`
- `agent-browser get url` -> `cmux browser <surface> get url`
- `agent-browser get title` -> `cmux browser <surface> get title`
@ -34,7 +34,13 @@ cmux browser <surface> get url|title
```bash
cmux browser <surface> snapshot --interactive
cmux browser <surface> snapshot --interactive --compact --max-depth 3
cmux browser <surface> get text|html|value|attr|count|box|styles ...
cmux browser <surface> get text body
cmux browser <surface> get html body
cmux browser <surface> get value "#email"
cmux browser <surface> get attr "#email" --attr placeholder
cmux browser <surface> get count ".row"
cmux browser <surface> get box "#submit"
cmux browser <surface> get styles "#submit" --property color
cmux browser <surface> eval '<js>'
```

View file

@ -10,6 +10,7 @@ if [ -f "$STATE_FILE" ]; then
fi
cmux browser "$SURFACE" goto "$DASHBOARD_URL"
cmux browser "$SURFACE" get url
cmux browser "$SURFACE" wait --load-state complete --timeout-ms 15000
cmux browser "$SURFACE" snapshot --interactive

View file

@ -5,6 +5,7 @@ URL="${1:-https://example.com/form}"
SURFACE="${2:-surface:1}"
cmux browser "$SURFACE" goto "$URL"
cmux browser "$SURFACE" get url
cmux browser "$SURFACE" wait --load-state complete --timeout-ms 15000
cmux browser "$SURFACE" snapshot --interactive

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
E2E: focusing a panel preserves its notification and triggers a flash.
E2E: focusing a panel clears its notification and triggers a flash.
Note: This uses the socket focus command (no assistive access needed).
"""
@ -74,12 +74,8 @@ def main() -> int:
client.send("x")
time.sleep(0.2)
if wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification became read after focus")
return 1
items = client.list_notifications()
if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items):
print("FAIL: Notification did not remain present and unread after focus")
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification did not become read after focus")
return 1
final_flash = client.flash_count(term_b)
@ -97,7 +93,7 @@ def main() -> int:
except Exception:
pass
print("PASS: Focus preserves notification and flashes panel")
print("PASS: Focus clears notification and flashes panel")
return 0
except (cmuxError, RuntimeError) as exc:
print(f"FAIL: {exc}")

View file

@ -58,15 +58,6 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout:
return last
def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool:
start = time.time()
while time.time() - start < timeout:
if client.current_workspace() == expected:
return True
time.sleep(0.05)
return client.current_workspace() == expected
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
surfaces = client.list_surfaces()
if len(surfaces) < 2:
@ -224,8 +215,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
return result
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On Panel Focus")
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Mark Read On Panel Focus")
try:
client.clear_notifications()
client.reset_flash_counts()
@ -238,88 +229,81 @@ def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
client.set_app_focus(False)
client.notify_surface(other[0], "focusread")
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["surface_id"] == other[1]), None)
if target is None or target["is_read"]:
result.failure("Expected unread notification for target surface before focus")
return result
time.sleep(0.1)
client.set_app_focus(True)
client.focus_surface(other[0])
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus")
return result
time.sleep(0.1)
items = client.list_notifications()
target = next((n for n in items if n["surface_id"] == other[1]), None)
if target is None:
result.failure("Expected notification for target surface")
elif target["is_read"]:
result.failure("Expected notification to remain unread on focus")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on focus")
else:
result.success("Notification persisted across panel focus")
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus dismissal")
else:
result.success("Notification marked read on focus")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On App Active")
def test_mark_read_on_app_active(client: cmux) -> TestResult:
result = TestResult("Mark Read On App Active")
try:
client.clear_notifications()
client.set_app_focus(False)
client.notify("activate")
items = wait_for_notifications(client, 1)
time.sleep(0.1)
items = client.list_notifications()
if not items or items[0]["is_read"]:
result.failure("Expected unread notification before activation")
return result
client.simulate_app_active()
items = wait_for_notifications(client, 1)
time.sleep(0.1)
items = client.list_notifications()
if not items:
result.failure("Expected notification to remain after activation")
elif items[0]["is_read"]:
result.failure("Expected notification to remain unread on app active")
elif not items[0]["is_read"]:
result.failure("Expected notification to be marked read on app active")
else:
result.success("Notification persisted across app activation")
result.success("Notification marked read on app active")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On Tab Switch")
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Mark Read On Tab Switch")
try:
client.clear_notifications()
client.set_app_focus(False)
tab1 = client.current_workspace()
client.notify("tabswitch")
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None or target["is_read"]:
result.failure("Expected unread notification for original tab before switching")
return result
time.sleep(0.1)
tab2 = client.new_workspace()
if not wait_for_current_workspace(client, tab2):
result.failure("Expected new workspace to become selected")
return result
time.sleep(0.1)
client.set_app_focus(True)
client.select_workspace(tab1)
if not wait_for_current_workspace(client, tab1):
result.failure("Expected original workspace to become selected again")
return result
time.sleep(0.1)
items = wait_for_notifications(client, 1)
items = client.list_notifications()
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None:
result.failure("Expected notification for original tab")
elif target["is_read"]:
result.failure("Expected notification to remain unread on tab switch")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on tab switch")
else:
result.success("Notification persisted across tab switch")
result.success("Notification marked read on tab switch")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -387,20 +371,11 @@ def test_focus_on_notification_click(client: cmux) -> TestResult:
result.failure("Expected notification surface to be focused")
return result
items = client.list_notifications()
notification = next((n for n in items if n["surface_id"] == other[1]), None)
if notification is None:
result.failure("Expected notification to remain listed after notification click")
return result
if notification["is_read"]:
result.failure("Expected notification click to preserve unread state")
return result
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure(f"Expected flash count >= 1, got {count}")
else:
result.success("Notification click focuses, flashes, and preserves unread state")
result.success("Notification click focuses and flashes panel")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -480,9 +455,9 @@ def run_tests() -> int:
results.append(test_kitty_notification_simple(client))
results.append(test_kitty_notification_chunked(client))
results.append(test_rxvt_notification_osc777(client))
results.append(test_preserve_unread_on_focus_change(client))
results.append(test_preserve_unread_on_app_active(client))
results.append(test_preserve_unread_on_tab_switch(client))
results.append(test_mark_read_on_focus_change(client))
results.append(test_mark_read_on_app_active(client))
results.append(test_mark_read_on_tab_switch(client))
results.append(test_flash_on_tab_switch(client))
results.append(test_focus_on_notification_click(client))
results.append(test_restore_focus_on_tab_switch(client))

View file

@ -91,6 +91,32 @@ def _run_cli_text(cli: str, args: list[str], retries: int = 3) -> str:
raise cmuxError(f"CLI failed ({' '.join(args)}): {last_merged}")
def _run_cli_tail_json(cli: str, args: list[str], retries: int = 3) -> dict:
last_merged = ""
for attempt in range(1, retries + 1):
proc = subprocess.run(
[cli, "--socket", SOCKET_PATH] + args,
capture_output=True,
text=True,
check=False,
)
if proc.returncode == 0:
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid CLI JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
merged = f"{proc.stdout}\n{proc.stderr}".strip()
last_merged = merged
if "Command timed out" in merged and attempt < retries:
time.sleep(0.2)
continue
raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}")
raise cmuxError(f"CLI failed ({' '.join(args)}): {last_merged}")
def _run_cli_expect_failure(cli: str, args: list[str], needles: list[str]) -> None:
proc = subprocess.run(
[cli, "--socket", SOCKET_PATH, "--json"] + args,
@ -144,15 +170,6 @@ def main() -> int:
cli = _find_cli_binary()
with _local_test_server() as page_url:
opened = _run_cli_json(cli, ["browser", "open", page_url])
surface = str(opened.get("surface_ref") or opened.get("surface_id") or "")
_must(bool(surface), f"browser open returned no surface handle: {opened}")
_must(surface.startswith("surface:"), f"Expected short surface ref from browser open, got: {opened}")
_run_cli_json(cli, ["browser", surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"])
snapshot_text = _run_cli_text(cli, ["browser", surface, "snapshot", "--interactive"])
_must("ref=e" in snapshot_text, f"Expected snapshot text with refs from CLI: {snapshot_text!r}")
identify = _run_cli_json(cli, ["identify"])
focused = identify.get("focused") or {}
workspace = str(
@ -163,6 +180,28 @@ def main() -> int:
or ""
)
_must(bool(workspace), f"Expected workspace handle from identify: {identify}")
os.environ["CMUX_WORKSPACE_ID"] = workspace
opened_tail_json = _run_cli_tail_json(
cli,
["browser", "open", page_url, "--workspace", workspace, "--id-format", "both", "--json"],
)
tail_surface = str(opened_tail_json.get("surface_ref") or "")
_must(tail_surface.startswith("surface:"), f"Expected trailing --json browser open to return surface_ref: {opened_tail_json}")
_must(bool(opened_tail_json.get("surface_id")), f"Expected trailing --id-format both to preserve surface_id: {opened_tail_json}")
_must("--json" not in str(opened_tail_json.get("url") or ""), f"Trailing output flags leaked into browser open URL: {opened_tail_json}")
_run_cli_json(cli, ["browser", tail_surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"])
tail_url_payload = _run_cli_json(cli, ["browser", tail_surface, "url"])
_must(str(tail_url_payload.get("url") or "").startswith(page_url), f"Expected trailing --json browser open to navigate: {tail_url_payload}")
opened = _run_cli_json(cli, ["browser", "open", page_url])
surface = str(opened.get("surface_ref") or opened.get("surface_id") or "")
_must(bool(surface), f"browser open returned no surface handle: {opened}")
_must(surface.startswith("surface:"), f"Expected short surface ref from browser open, got: {opened}")
_run_cli_json(cli, ["browser", surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"])
snapshot_text = _run_cli_text(cli, ["browser", surface, "snapshot", "--interactive"])
_must("ref=e" in snapshot_text, f"Expected snapshot text with refs from CLI: {snapshot_text!r}")
blank_opened = _run_cli_json(cli, ["browser", "open", "about:blank", "--workspace", workspace])
blank_surface = str(blank_opened.get("surface_ref") or blank_opened.get("surface_id") or "")
@ -179,6 +218,14 @@ def main() -> int:
_must(routed_url.startswith(page_url), f"Expected routed URL to start with page URL, got: {routed_url_payload}")
_must("--workspace" not in routed_url and "--window" not in routed_url, f"Routing flags leaked into URL: {routed_url_payload}")
goto_url = f"{page_url}?goto=1"
goto_payload = _run_cli_json(cli, ["browser", surface, "goto", goto_url, "--snapshot-after"])
_must(bool(goto_payload.get("post_action_snapshot")), f"Expected goto --snapshot-after to include post_action_snapshot: {goto_payload}")
goto_url_payload = _run_cli_json(cli, ["browser", surface, "url"])
current_goto_url = str(goto_url_payload.get("url") or "")
_must(current_goto_url.startswith(goto_url), f"Expected goto --snapshot-after current URL to match target URL: {goto_url_payload}")
_must("--snapshot-after" not in current_goto_url, f"Expected goto URL to exclude trailing flag text: {goto_url_payload}")
find_text = _run_cli_json(cli, ["browser", surface, "find", "text", "row-b"])
_must(str(find_text.get("element_ref") or "").startswith("@e"), f"Expected element_ref from find text: {find_text}")

View file

@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Regression: background workspaces should refresh git branch after external repo changes."""
from __future__ import annotations
import glob
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
def _resolve_socket_path() -> str:
socket_path = os.environ.get("CMUX_SOCKET", "").strip()
if not socket_path:
raise cmuxError("CMUX_SOCKET is required (expected /tmp/cmux-debug-<tag>.sock)")
if not re.fullmatch(r"/tmp/cmux-debug-[^/]+\.sock", socket_path):
raise cmuxError(f"CMUX_SOCKET must be a tagged debug socket, got: {socket_path!r}")
return socket_path
SOCKET_PATH = _resolve_socket_path()
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(
os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"),
recursive=True,
)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: list[str]) -> str:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return (proc.stdout or "").strip()
def _parse_sidebar_state(text: str) -> dict[str, str]:
parsed: dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or "=" not in line:
continue
key, value = line.split("=", 1)
parsed[key.strip()] = value.strip()
return parsed
def _wait_for_sidebar_branch(
cli: str,
workspace: str,
expected_branch: str,
timeout: float = 15.0,
) -> dict[str, str]:
deadline = time.time() + timeout
last_state = ""
while time.time() < deadline:
state_text = _run_cli(cli, ["sidebar-state", "--workspace", workspace])
last_state = state_text
state = _parse_sidebar_state(state_text)
raw_branch = state.get("git_branch", "")
observed_branch = raw_branch.split(" ", 1)[0]
if observed_branch == expected_branch:
return state
time.sleep(0.1)
raise cmuxError(
f"Timed out waiting for branch {expected_branch!r} on workspace {workspace}. "
f"Last sidebar-state: {last_state!r}"
)
def _create_git_repo(root: Path) -> Path:
repo = root / "repo"
repo.mkdir(parents=True, exist_ok=True)
subprocess.run(
["git", "-c", "init.defaultBranch=main", "init"],
cwd=repo,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
["git", "config", "user.name", "cmux-test"],
cwd=repo,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
["git", "config", "user.email", "cmux-test@example.com"],
cwd=repo,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
(repo / "README.md").write_text("issue 915 external refresh\n", encoding="utf-8")
subprocess.run(
["git", "add", "README.md"],
cwd=repo,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
["git", "-c", "commit.gpgsign=false", "commit", "-m", "init"],
cwd=repo,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return repo
def main() -> int:
cli = _find_cli_binary()
temp_root = Path(tempfile.mkdtemp(prefix="cmux_issue_915_external_git_"))
created_workspace: str | None = None
try:
repo_path = _create_git_repo(temp_root)
with cmux(SOCKET_PATH) as client:
baseline_workspace = client.current_workspace()
created = _run_cli(cli, ["new-workspace", "--cwd", str(repo_path)])
_must(created.startswith("OK "), f"new-workspace expected OK response, got: {created!r}")
created_workspace = created.removeprefix("OK ").strip()
_must(bool(created_workspace), f"new-workspace returned no workspace handle: {created!r}")
_must(
client.current_workspace() == baseline_workspace,
"new-workspace --cwd should preserve selected workspace",
)
initial_state = _wait_for_sidebar_branch(cli, created_workspace, "main")
_must(
initial_state.get("cwd", "") == str(repo_path),
f"Expected sidebar cwd={repo_path!r}, got {initial_state.get('cwd', '')!r}",
)
subprocess.run(
["git", "checkout", "-b", "feature/external-refresh"],
cwd=repo_path,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
refreshed_state = _wait_for_sidebar_branch(
cli,
created_workspace,
"feature/external-refresh",
timeout=15.0,
)
_must(
refreshed_state.get("cwd", "") == str(repo_path),
f"Expected refreshed sidebar cwd={repo_path!r}, got {refreshed_state.get('cwd', '')!r}",
)
_must(
client.current_workspace() == baseline_workspace,
"external git branch refresh should not switch selected workspace",
)
finally:
if created_workspace:
try:
_run_cli(cli, ["close-workspace", "--workspace", created_workspace])
except Exception:
pass
shutil.rmtree(temp_root, ignore_errors=True)
print("PASS: background workspace git branch refreshes after external repo checkout")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
E2E: focusing a panel preserves its notification and triggers a flash.
E2E: focusing a panel clears its notification and triggers a flash.
Note: This uses the socket focus command (no assistive access needed).
"""
@ -74,12 +74,8 @@ def main() -> int:
client.send("x")
time.sleep(0.2)
if wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification became read after focus")
return 1
items = client.list_notifications()
if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items):
print("FAIL: Notification did not remain present and unread after focus")
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification did not become read after focus")
return 1
final_flash = client.flash_count(term_b)
@ -97,7 +93,7 @@ def main() -> int:
except Exception:
pass
print("PASS: Focus preserves notification and flashes panel")
print("PASS: Focus clears notification and flashes panel")
return 0
except (cmuxError, RuntimeError) as exc:
print(f"FAIL: {exc}")

View file

@ -58,15 +58,6 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout:
return last
def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool:
start = time.time()
while time.time() - start < timeout:
if client.current_workspace() == expected:
return True
time.sleep(0.05)
return client.current_workspace() == expected
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
surfaces = client.list_surfaces()
if len(surfaces) < 2:
@ -224,8 +215,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
return result
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On Panel Focus")
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Mark Read On Panel Focus")
try:
client.clear_notifications()
client.reset_flash_counts()
@ -238,88 +229,81 @@ def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
client.set_app_focus(False)
client.notify_surface(other[0], "focusread")
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["surface_id"] == other[1]), None)
if target is None or target["is_read"]:
result.failure("Expected unread notification for target surface before focus")
return result
time.sleep(0.1)
client.set_app_focus(True)
client.focus_surface(other[0])
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus")
return result
time.sleep(0.1)
items = client.list_notifications()
target = next((n for n in items if n["surface_id"] == other[1]), None)
if target is None:
result.failure("Expected notification for target surface")
elif target["is_read"]:
result.failure("Expected notification to remain unread on focus")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on focus")
else:
result.success("Notification persisted across panel focus")
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus dismissal")
else:
result.success("Notification marked read on focus")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On App Active")
def test_mark_read_on_app_active(client: cmux) -> TestResult:
result = TestResult("Mark Read On App Active")
try:
client.clear_notifications()
client.set_app_focus(False)
client.notify("activate")
items = wait_for_notifications(client, 1)
time.sleep(0.1)
items = client.list_notifications()
if not items or items[0]["is_read"]:
result.failure("Expected unread notification before activation")
return result
client.simulate_app_active()
items = wait_for_notifications(client, 1)
time.sleep(0.1)
items = client.list_notifications()
if not items:
result.failure("Expected notification to remain after activation")
elif items[0]["is_read"]:
result.failure("Expected notification to remain unread on app active")
elif not items[0]["is_read"]:
result.failure("Expected notification to be marked read on app active")
else:
result.success("Notification persisted across app activation")
result.success("Notification marked read on app active")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On Tab Switch")
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Mark Read On Tab Switch")
try:
client.clear_notifications()
client.set_app_focus(False)
tab1 = client.current_workspace()
client.notify("tabswitch")
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None or target["is_read"]:
result.failure("Expected unread notification for original tab before switching")
return result
time.sleep(0.1)
tab2 = client.new_workspace()
if not wait_for_current_workspace(client, tab2):
result.failure("Expected new workspace to become selected")
return result
time.sleep(0.1)
client.set_app_focus(True)
client.select_workspace(tab1)
if not wait_for_current_workspace(client, tab1):
result.failure("Expected original workspace to become selected again")
return result
time.sleep(0.1)
items = wait_for_notifications(client, 1)
items = client.list_notifications()
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None:
result.failure("Expected notification for original tab")
elif target["is_read"]:
result.failure("Expected notification to remain unread on tab switch")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on tab switch")
else:
result.success("Notification persisted across tab switch")
result.success("Notification marked read on tab switch")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -387,20 +371,11 @@ def test_focus_on_notification_click(client: cmux) -> TestResult:
result.failure("Expected notification surface to be focused")
return result
items = client.list_notifications()
notification = next((n for n in items if n["surface_id"] == other[1]), None)
if notification is None:
result.failure("Expected notification to remain listed after notification click")
return result
if notification["is_read"]:
result.failure("Expected notification click to preserve unread state")
return result
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure(f"Expected flash count >= 1, got {count}")
else:
result.success("Notification click focuses, flashes, and preserves unread state")
result.success("Notification click focuses and flashes panel")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -480,9 +455,9 @@ def run_tests() -> int:
results.append(test_kitty_notification_simple(client))
results.append(test_kitty_notification_chunked(client))
results.append(test_rxvt_notification_osc777(client))
results.append(test_preserve_unread_on_focus_change(client))
results.append(test_preserve_unread_on_app_active(client))
results.append(test_preserve_unread_on_tab_switch(client))
results.append(test_mark_read_on_focus_change(client))
results.append(test_mark_read_on_app_active(client))
results.append(test_mark_read_on_tab_switch(client))
results.append(test_flash_on_tab_switch(client))
results.append(test_focus_on_notification_click(client))
results.append(test_restore_focus_on_tab_switch(client))

3
web/.env.example Normal file
View file

@ -0,0 +1,3 @@
RESEND_API_KEY=
CMUX_FEEDBACK_FROM_EMAIL=
CMUX_FEEDBACK_RATE_LIMIT_ID=

View file

@ -0,0 +1,340 @@
import { checkRateLimit } from "@vercel/firewall";
import { NextResponse } from "next/server";
import { Resend } from "resend";
import { z } from "zod";
import { env } from "@/app/env";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const feedbackRecipient = "feedback@manaflow.com";
const maxAttachmentCount = 10;
const maxAttachmentBytes = 4 * 1024 * 1024;
// Keep multipart requests below Vercel Functions' 4.5 MB request-body limit.
const maxTotalAttachmentBytes = 4 * 1024 * 1024;
const allowedImageTypes = new Set([
"image/gif",
"image/heic",
"image/heif",
"image/jpeg",
"image/png",
"image/tiff",
"image/webp",
]);
const feedbackSchema = z.object({
email: z.string().trim().email().max(320),
message: z.string().trim().min(1).max(4000),
appVersion: z.string().trim().max(120).optional().default(""),
appBuild: z.string().trim().max(120).optional().default(""),
appCommit: z.string().trim().max(120).optional().default(""),
bundleIdentifier: z.string().trim().max(200).optional().default(""),
osVersion: z.string().trim().max(200).optional().default(""),
locale: z.string().trim().max(120).optional().default(""),
});
type PreparedAttachment = {
content: Buffer;
contentType: string;
filename: string;
size: number;
};
export async function POST(request: Request) {
const feedbackConfig = resolveFeedbackConfig();
if (!feedbackConfig) {
return jsonError("Feedback endpoint is not configured", 503);
}
if (process.env.VERCEL === "1") {
const { error, rateLimited } = await checkRateLimit(
feedbackConfig.rateLimitId,
{ request },
);
if (rateLimited || error === "blocked") {
return jsonError("Rate limit exceeded", 429);
}
if (error === "not-found") {
console.error(
"feedback.route.rate_limit_not_found",
feedbackConfig.rateLimitId,
);
} else if (error) {
console.error("feedback.route.rate_limit_error", error);
}
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return jsonError("Invalid multipart payload", 400);
}
const parsed = feedbackSchema.safeParse({
email: getString(formData, "email"),
message: getString(formData, "message"),
appVersion: getString(formData, "appVersion"),
appBuild: getString(formData, "appBuild"),
appCommit: getString(formData, "appCommit"),
bundleIdentifier: getString(formData, "bundleIdentifier"),
osVersion: getString(formData, "osVersion"),
locale: getString(formData, "locale"),
});
if (!parsed.success) {
return jsonError("Invalid feedback payload", 400);
}
const attachmentsResult = await prepareAttachments(
formData.getAll("attachments"),
);
if ("errorResponse" in attachmentsResult) {
return attachmentsResult.errorResponse;
}
const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } =
parsed.data;
const subject = buildSubject(email, message, appVersion);
const attachments = attachmentsResult.attachments;
const resend = new Resend(feedbackConfig.resendApiKey);
const { error } = await resend.emails.send({
from: `Manaflow <${feedbackConfig.fromEmail}>`,
to: [feedbackRecipient],
replyTo: email,
subject,
text: buildTextBody({
email,
message,
appVersion,
appBuild,
appCommit,
bundleIdentifier,
osVersion,
locale,
attachments,
}),
html: buildHtmlBody({
email,
message,
appVersion,
appBuild,
appCommit,
bundleIdentifier,
osVersion,
locale,
attachments,
}),
attachments: attachments.map((attachment) => ({
content: attachment.content,
contentType: attachment.contentType,
filename: attachment.filename,
})),
});
if (error) {
console.error("feedback.route.resend_failed", error);
return jsonError("Failed to send feedback", 502);
}
return NextResponse.json(
{ ok: true },
{
headers: {
"Cache-Control": "no-store",
},
},
);
}
function resolveFeedbackConfig() {
const resendApiKey = env.RESEND_API_KEY;
const fromEmail = env.CMUX_FEEDBACK_FROM_EMAIL;
const rateLimitId = env.CMUX_FEEDBACK_RATE_LIMIT_ID;
if (!resendApiKey || !fromEmail || !rateLimitId) {
return null;
}
return {
resendApiKey,
fromEmail,
rateLimitId,
};
}
function getString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
async function prepareAttachments(values: FormDataEntryValue[]) {
const files = values.filter(
(value): value is File => value instanceof File && value.name.length > 0,
);
if (files.length > maxAttachmentCount) {
return {
errorResponse: jsonError("Too many images attached", 400),
};
}
let totalSize = 0;
const attachments: PreparedAttachment[] = [];
for (const file of files) {
if (!allowedImageTypes.has(file.type)) {
return {
errorResponse: jsonError("Unsupported image attachment type", 415),
};
}
if (file.size > maxAttachmentBytes) {
return {
errorResponse: jsonError("Image attachment is too large", 413),
};
}
totalSize += file.size;
if (totalSize > maxTotalAttachmentBytes) {
return {
errorResponse: jsonError("Total image attachment size is too large", 413),
};
}
attachments.push({
content: Buffer.from(await file.arrayBuffer()),
contentType: file.type,
filename: sanitizeFilename(file.name),
size: file.size,
});
}
return { attachments };
}
function buildSubject(email: string, message: string, appVersion: string) {
const firstNonEmptyLine =
message
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? "Feedback";
const summary =
firstNonEmptyLine.length > 72
? `${firstNonEmptyLine.slice(0, 69)}...`
: firstNonEmptyLine;
const versionSuffix = appVersion ? ` (v${appVersion})` : "";
return `cmux feedback from ${email}${versionSuffix}: ${summary}`;
}
function buildTextBody(input: {
email: string;
message: string;
appVersion: string;
appBuild: string;
appCommit: string;
bundleIdentifier: string;
osVersion: string;
locale: string;
attachments: PreparedAttachment[];
}) {
const attachmentLines =
input.attachments.length === 0
? "Attachments: none"
: [
"Attachments:",
...input.attachments.map(
(attachment) =>
`- ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`,
),
].join("\n");
return [
`From: ${input.email}`,
`App version: ${input.appVersion || "unknown"}`,
`App build: ${input.appBuild || "unknown"}`,
`App commit: ${input.appCommit || "unknown"}`,
`Bundle identifier: ${input.bundleIdentifier || "unknown"}`,
`macOS: ${input.osVersion || "unknown"}`,
`Locale: ${input.locale || "unknown"}`,
attachmentLines,
"",
"Message:",
input.message,
].join("\n");
}
function buildHtmlBody(input: {
email: string;
message: string;
appVersion: string;
appBuild: string;
appCommit: string;
bundleIdentifier: string;
osVersion: string;
locale: string;
attachments: PreparedAttachment[];
}) {
const attachmentMarkup =
input.attachments.length === 0
? "<p><strong>Attachments:</strong> none</p>"
: `<p><strong>Attachments:</strong></p><ul>${input.attachments
.map(
(attachment) =>
`<li>${escapeHtml(attachment.filename)} (${escapeHtml(
attachment.contentType,
)}, ${attachment.size} bytes)</li>`,
)
.join("")}</ul>`;
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;line-height:1.5">
<h1 style="font-size:18px;margin:0 0 16px">cmux feedback</h1>
<p><strong>From:</strong> ${escapeHtml(input.email)}</p>
<p><strong>App version:</strong> ${escapeHtml(input.appVersion || "unknown")}</p>
<p><strong>App build:</strong> ${escapeHtml(input.appBuild || "unknown")}</p>
<p><strong>App commit:</strong> ${escapeHtml(input.appCommit || "unknown")}</p>
<p><strong>Bundle identifier:</strong> ${escapeHtml(
input.bundleIdentifier || "unknown",
)}</p>
<p><strong>macOS:</strong> ${escapeHtml(input.osVersion || "unknown")}</p>
<p><strong>Locale:</strong> ${escapeHtml(input.locale || "unknown")}</p>
${attachmentMarkup}
<h2 style="font-size:15px;margin:24px 0 8px">Message</h2>
<pre style="white-space:pre-wrap;font:13px/1.6 SFMono-Regular,Menlo,monospace;background:#f3f4f6;border-radius:10px;padding:12px">${escapeHtml(
input.message,
)}</pre>
</div>
`.trim();
}
function sanitizeFilename(fileName: string) {
const cleaned = fileName.replace(/[\r\n"]/g, "").trim();
return cleaned.length > 0 ? cleaned : "attachment";
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function jsonError(message: string, status: number) {
return NextResponse.json(
{ error: message },
{
status,
headers: {
"Cache-Control": "no-store",
},
},
);
}

18
web/app/env.ts Normal file
View file

@ -0,0 +1,18 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
RESEND_API_KEY: z.string().min(1),
CMUX_FEEDBACK_FROM_EMAIL: z.string().email(),
CMUX_FEEDBACK_RATE_LIMIT_ID: z.string().min(1),
},
runtimeEnv: {
RESEND_API_KEY: process.env.RESEND_API_KEY,
CMUX_FEEDBACK_FROM_EMAIL: process.env.CMUX_FEEDBACK_FROM_EMAIL,
CMUX_FEEDBACK_RATE_LIMIT_ID: process.env.CMUX_FEEDBACK_RATE_LIMIT_ID,
},
skipValidation:
process.env.SKIP_ENV_VALIDATION === "1" ||
process.env.VERCEL_ENV === "preview",
});

View file

@ -5,6 +5,8 @@
"": {
"name": "web",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.10",
"@vercel/firewall": "^1.1.2",
"next": "16.1.6",
"next-themes": "^0.4.6",
"posthog-js": "^1.350.0",
@ -12,7 +14,9 @@
"react-dom": "19.2.3",
"react-tweet": "^3.3.0",
"react-wrap-balancer": "^1.1.1",
"resend": "^6.9.3",
"shiki": "^3.22.0",
"zod": "^4.3.6",
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -249,8 +253,14 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="],
"@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.10", "", { "dependencies": { "@t3-oss/env-core": "0.13.10" }, "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@ -363,6 +373,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@vercel/firewall": ["@vercel/firewall@1.1.2", "", {}, "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@ -543,6 +555,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@ -819,6 +833,8 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postal-mime": ["postal-mime@2.7.3", "", {}, "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"posthog-js": ["posthog-js@1.350.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.23.0", "@posthog/types": "1.350.0", "core-js": "^3.38.1", "dompurify": "^3.3.1", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-Ab+dyQdlKUTrfUZ12+fvcBo75S4jw/3o2gMleDga21B1v9c15yybiX4S3JrX66uh5L1DYG1H8sxtd4BXIIodjQ=="],
@ -859,6 +875,8 @@
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"resend": ["resend@6.9.3", "", { "dependencies": { "postal-mime": "2.7.3", "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@ -907,6 +925,8 @@
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
@ -933,6 +953,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="],
"swr": ["swr@2.4.0", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@ -987,6 +1009,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],

View file

@ -1,3 +1,4 @@
import "./app/env";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {

192
web/package-lock.json generated
View file

@ -9,13 +9,18 @@
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.10",
"@vercel/firewall": "^1.1.2",
"next": "16.1.6",
"next-themes": "^0.4.6",
"posthog-js": "^1.350.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-tweet": "^3.3.0",
"react-wrap-balancer": "^1.1.1",
"shiki": "^3.22.0"
"resend": "^6.9.3",
"shiki": "^3.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -1630,6 +1635,12 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -1639,6 +1650,61 @@
"tslib": "^2.8.0"
}
},
"node_modules/@t3-oss/env-core": {
"version": "0.13.10",
"resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.13.10.tgz",
"integrity": "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==",
"license": "MIT",
"peerDependencies": {
"arktype": "^2.1.0",
"typescript": ">=5.0.0",
"valibot": "^1.0.0-beta.7 || ^1.0.0",
"zod": "^3.24.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"arktype": {
"optional": true
},
"typescript": {
"optional": true
},
"valibot": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@t3-oss/env-nextjs": {
"version": "0.13.10",
"resolved": "https://registry.npmjs.org/@t3-oss/env-nextjs/-/env-nextjs-0.13.10.tgz",
"integrity": "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ==",
"license": "MIT",
"dependencies": {
"@t3-oss/env-core": "0.13.10"
},
"peerDependencies": {
"arktype": "^2.1.0",
"typescript": ">=5.0.0",
"valibot": "^1.0.0-beta.7 || ^1.0.0",
"zod": "^3.24.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"arktype": {
"optional": true
},
"typescript": {
"optional": true
},
"valibot": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
@ -2559,6 +2625,15 @@
"win32"
]
},
"node_modules/@vercel/firewall": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@vercel/firewall/-/firewall-1.1.2.tgz",
"integrity": "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@ -3055,6 +3130,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -4031,6 +4115,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -6044,6 +6134,12 @@
"node": ">= 0.4"
}
},
"node_modules/postal-mime": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -6225,6 +6321,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-tweet": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-tweet/-/react-tweet-3.3.0.tgz",
"integrity": "sha512-gSIG2169ZK7UH6rBzuU+j1xnQbH3IlOTLEkuGrRiJJTMgETik+h+26yHyyVKrLkzwrOaYPk4K3OtEKycqKgNLw==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.3",
"clsx": "^2.0.0",
"swr": "^2.2.4"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-wrap-balancer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/react-wrap-balancer/-/react-wrap-balancer-1.1.1.tgz",
@ -6302,6 +6413,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resend": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz",
"integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.3",
"svix": "1.84.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -6695,6 +6827,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@ -6908,6 +7050,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svix": {
"version": "1.84.1",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
"integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0",
"uuid": "^10.0.0"
}
},
"node_modules/swr": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz",
@ -7140,7 +7305,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -7343,6 +7508,28 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@ -7515,7 +7702,6 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View file

@ -10,6 +10,8 @@
"lint": "eslint"
},
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.10",
"@vercel/firewall": "^1.1.2",
"next": "16.1.6",
"next-themes": "^0.4.6",
"posthog-js": "^1.350.0",
@ -17,7 +19,9 @@
"react-dom": "19.2.3",
"react-tweet": "^3.3.0",
"react-wrap-balancer": "^1.1.1",
"shiki": "^3.22.0"
"resend": "^6.9.3",
"shiki": "^3.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",