diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 463e5a56..a51a96c0 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c106a18a..6c1de0eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index da320e73..3b9a0866 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57bfb154..200f003a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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' diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 48b5e4de..23d595c7 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -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 \ diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4172bb7e..44989796 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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 { diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 03578fe1..773b1e73 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -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 = ""; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; + B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; @@ -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 */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2139e387..dd1a936a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b015a5ea..9b2f218e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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?) { diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index da7be546..cb12b170 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import ObjectiveC +import SwiftUI import WebKit private var cmuxWindowBrowserPortalKey: UInt8 = 0 @@ -24,6 +25,28 @@ final class WindowBrowserHostView: NSView { let isVertical: Bool } + private struct DividerHit { + let kind: DividerCursorKind + let isInHostedContent: Bool + } + + private struct HostedInspectorDividerHit { + let slotView: WindowBrowserSlotView + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + } + + private struct HostedInspectorDividerDragState { + let slotView: WindowBrowserSlotView + let containerView: NSView + let pageView: NSView + let inspectorView: NSView + let initialWindowX: CGFloat + let initialPageFrame: NSRect + let initialInspectorFrame: NSRect + } + private enum DividerCursorKind: Equatable { case vertical case horizontal @@ -39,10 +62,54 @@ final class WindowBrowserHostView: NSView { override var isOpaque: Bool { false } private static let sidebarLeadingEdgeEpsilon: CGFloat = 1 private static let minimumVisibleLeadingContentWidth: CGFloat = 24 + private static let hostedInspectorDividerHitExpansion: CGFloat = 6 + private static let minimumHostedInspectorWidth: CGFloat = 120 private var cachedSidebarDividerX: CGFloat? private var sidebarDividerMissCount = 0 private var trackingArea: NSTrackingArea? private var activeDividerCursorKind: DividerCursorKind? + private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? + +#if DEBUG + private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool { + switch event?.type { + case .leftMouseDown, .leftMouseDragged, .leftMouseUp: + return true + default: + return false + } + } + + private func debugLogPointerRouting( + stage: String, + point: NSPoint, + titlebarPassThrough: Bool, + sidebarPassThrough: Bool, + dividerHit: DividerHit?, + hitView: NSView? + ) { + let event = NSApp.currentEvent + guard Self.shouldLogPointerEvent(event) else { return } + + let hitDesc: String = { + guard let hitView else { return "nil" } + return "\(type(of: hitView))@\(browserPortalDebugToken(hitView))" + }() + let dividerDesc: String = { + guard let dividerHit else { return "nil" } + let kind = dividerHit.kind == .vertical ? "vertical" : "horizontal" + return "kind=\(kind),hosted=\(dividerHit.isInHostedContent ? 1 : 0)" + }() + let windowPoint = convert(point, to: nil) + dlog( + "browser.portal.pointer stage=\(stage) event=\(String(describing: event?.type)) " + + "host=\(browserPortalDebugToken(self)) point=\(browserPortalDebugFrame(NSRect(origin: point, size: .zero))) " + + "windowPoint=\(browserPortalDebugFrame(NSRect(origin: windowPoint, size: .zero))) " + + "titlebar=\(titlebarPassThrough ? 1 : 0) sidebar=\(sidebarPassThrough ? 1 : 0) " + + "divider=\(dividerDesc) hit=\(hitDesc)" + ) + } +#endif override func viewDidMoveToWindow() { super.viewDidMoveToWindow() @@ -62,9 +129,29 @@ final class WindowBrowserHostView: NSView { window?.invalidateCursorRects(for: self) } + override func layout() { + super.layout() + reapplyHostedInspectorDividersIfNeeded(reason: "host.layout") + } + + override func didAddSubview(_ subview: NSView) { + super.didAddSubview(subview) + guard let slot = subview as? WindowBrowserSlotView else { return } + slot.onHostedInspectorLayout = { [weak self] slotView in + self?.reapplyHostedInspectorDividerIfNeeded(in: slotView, reason: "slot.layout") + } + } + + override func willRemoveSubview(_ subview: NSView) { + if let slot = subview as? WindowBrowserSlotView { + slot.onHostedInspectorLayout = nil + } + super.willRemoveSubview(subview) + } + override func resetCursorRects() { super.resetCursorRects() - guard let window, let rootView = window.contentView else { return } + guard let rootView = dividerSearchRootView() else { return } var regions: [DividerRegion] = [] Self.collectSplitDividerRegions(in: rootView, into: ®ions) let expansion: CGFloat = 4 @@ -113,18 +200,57 @@ final class WindowBrowserHostView: NSView { } override func hitTest(_ point: NSPoint) -> NSView? { - updateDividerCursor(at: point) + let dividerHit = splitDividerHit(at: point) + let hostedInspectorHit = dividerHit == nil ? hostedInspectorDividerHit(at: point) : nil + updateDividerCursor(at: point, dividerHit: dividerHit, hostedInspectorHit: hostedInspectorHit) - if shouldPassThroughToTitlebar(at: point) { - return nil - } - if shouldPassThroughToSidebarResizer(at: point) { - return nil - } - if shouldPassThroughToSplitDivider(at: point) { - return nil - } + let titlebarPassThrough = shouldPassThroughToTitlebar(at: point) + let sidebarPassThrough = shouldPassThroughToSidebarResizer( + at: point, + dividerHit: dividerHit, + hostedInspectorHit: hostedInspectorHit + ) + let splitPassThrough = dividerHit.map { !$0.isInHostedContent } ?? false + if titlebarPassThrough { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.titlebarPass", + point: point, + titlebarPassThrough: true, + sidebarPassThrough: sidebarPassThrough, + dividerHit: dividerHit, + hitView: nil + ) +#endif + return nil + } + if sidebarPassThrough { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.sidebarPass", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: true, + dividerHit: dividerHit, + hitView: nil + ) +#endif + return nil + } + if splitPassThrough { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.splitPass", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: false, + dividerHit: dividerHit, + hitView: nil + ) +#endif + return nil + } // Mirror terminal portal routing: while tab-reorder drags are active, // pass through to SwiftUI drop targets behind the portal host. // Browser hover routing also arrives as cursor/enter events and may not @@ -135,10 +261,143 @@ final class WindowBrowserHostView: NSView { ) { return nil } + + if let hostedInspectorHit { + if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) { +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.hostedInspectorNative", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: false, + dividerHit: DividerHit(kind: .vertical, isInHostedContent: true), + hitView: nativeHit + ) +#endif + return nativeHit + } +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.hostedInspectorManual", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: false, + dividerHit: DividerHit(kind: .vertical, isInHostedContent: true), + hitView: hostedInspectorHit.inspectorView + ) +#endif + return self + } let hitView = super.hitTest(point) +#if DEBUG + debugLogPointerRouting( + stage: "hitTest.result", + point: point, + titlebarPassThrough: false, + sidebarPassThrough: false, + dividerHit: dividerHit, + hitView: hitView === self ? nil : hitView + ) +#endif return hitView === self ? nil : hitView } + 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( + slotView: hostedInspectorHit.slotView, + containerView: hostedInspectorHit.containerView, + pageView: hostedInspectorHit.pageView, + inspectorView: hostedInspectorHit.inspectorView, + initialWindowX: event.locationInWindow.x, + initialPageFrame: hostedInspectorHit.pageView.frame, + initialInspectorFrame: hostedInspectorHit.inspectorView.frame + ) +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=start slot=\(browserPortalDebugToken(hostedInspectorHit.slotView)) " + + "page=\(browserPortalDebugToken(hostedInspectorHit.pageView)) " + + "inspector=\(browserPortalDebugToken(hostedInspectorHit.inspectorView)) " + + "pageFrame=\(browserPortalDebugFrame(hostedInspectorHit.pageView.frame)) " + + "inspectorFrame=\(browserPortalDebugFrame(hostedInspectorHit.inspectorView.frame))" + ) +#endif + } + + override func mouseDragged(with event: NSEvent) { + guard let dragState = hostedInspectorDividerDrag else { + super.mouseDragged(with: event) + return + } + guard dragState.slotView.window === window else { + hostedInspectorDividerDrag = nil + 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) + + dragState.slotView.preferredHostedInspectorWidth = inspectorWidth + let appliedFrames = applyHostedInspectorDividerWidth( + inspectorWidth, + to: HostedInspectorDividerHit( + slotView: dragState.slotView, + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView + ), + reason: "drag" + ) + updateDividerCursor( + at: convert(event.locationInWindow, from: nil), + dividerHit: nil, + hostedInspectorHit: HostedInspectorDividerHit( + slotView: dragState.slotView, + containerView: dragState.containerView, + pageView: dragState.pageView, + inspectorView: dragState.inspectorView + ) + ) +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=update slot=\(browserPortalDebugToken(dragState.slotView)) " + + "dividerX=\(String(format: "%.1f", clampedDividerX)) " + + "pageFrame=\(browserPortalDebugFrame(appliedFrames.pageFrame)) " + + "inspectorFrame=\(browserPortalDebugFrame(appliedFrames.inspectorFrame))" + ) +#endif + } + + override func mouseUp(with event: NSEvent) { + if let dragState = hostedInspectorDividerDrag { +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=end slot=\(browserPortalDebugToken(dragState.slotView)) " + + "pageFrame=\(browserPortalDebugFrame(dragState.pageView.frame)) " + + "inspectorFrame=\(browserPortalDebugFrame(dragState.inspectorView.frame))" + ) +#endif + scheduleHostedInspectorDividerReapply(in: dragState.slotView, reason: "dragEndAsync") + } + hostedInspectorDividerDrag = nil + updateDividerCursor(at: convert(event.locationInWindow, from: nil)) + super.mouseUp(with: event) + } + private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool { guard let window else { return false } // Window-level portal hosts sit above SwiftUI content. Never intercept @@ -152,6 +411,31 @@ final class WindowBrowserHostView: NSView { } private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { + let dividerHit = splitDividerHit(at: point) + let hostedInspectorHit = dividerHit == nil ? hostedInspectorDividerHit(at: point) : nil + return shouldPassThroughToSidebarResizer( + at: point, + dividerHit: dividerHit, + hostedInspectorHit: hostedInspectorHit + ) + } + + private func shouldPassThroughToSidebarResizer( + at point: NSPoint, + dividerHit: DividerHit?, + hostedInspectorHit: HostedInspectorDividerHit? = nil + ) -> Bool { + // If WebKit has a hosted vertical inspector split collapsed to the pane edge, + // prefer that divider over the app/sidebar resize hit zone. + if let dividerHit, + dividerHit.isInHostedContent, + dividerHit.kind == .vertical { + return false + } + if hostedInspectorHit != nil { + return false + } + // Browser portal host sits above SwiftUI content. Allow pointer/mouse events // to reach the SwiftUI sidebar divider resizer zone. let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } @@ -202,13 +486,24 @@ final class WindowBrowserHostView: NSView { return point.x >= regionMinX && point.x <= regionMaxX } - private func updateDividerCursor(at point: NSPoint) { - if shouldPassThroughToSidebarResizer(at: point) { + private func updateDividerCursor( + at point: NSPoint, + dividerHit: DividerHit? = nil, + hostedInspectorHit: HostedInspectorDividerHit? = nil + ) { + let resolvedDividerHit = dividerHit ?? splitDividerHit(at: point) + let resolvedHostedInspectorHit = resolvedDividerHit == nil ? (hostedInspectorHit ?? hostedInspectorDividerHit(at: point)) : nil + if shouldPassThroughToSidebarResizer( + at: point, + dividerHit: resolvedDividerHit, + hostedInspectorHit: resolvedHostedInspectorHit + ) { clearActiveDividerCursor(restoreArrow: false) return } - guard let nextKind = splitDividerCursorKind(at: point) else { + let nextKind = resolvedDividerHit?.kind ?? (resolvedHostedInspectorHit == nil ? nil : .vertical) + guard let nextKind else { clearActiveDividerCursor(restoreArrow: true) return } @@ -216,6 +511,26 @@ final class WindowBrowserHostView: NSView { nextKind.cursor.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 clearActiveDividerCursor(restoreArrow: Bool) { guard activeDividerCursorKind != nil else { return } window?.invalidateCursorRects(for: self) @@ -225,15 +540,25 @@ final class WindowBrowserHostView: NSView { } } - private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? { - guard let window else { return nil } + private func splitDividerHit(at point: NSPoint) -> DividerHit? { + guard window != nil else { return nil } let windowPoint = convert(point, to: nil) - guard let rootView = window.contentView else { return nil } - return Self.dividerCursorKind(at: windowPoint, in: rootView) + guard let rootView = dividerSearchRootView() else { return nil } + return Self.dividerHit(at: windowPoint, in: rootView, hostView: self) + } + + private func dividerSearchRootView() -> NSView? { + if let container = superview { + return container + } + return window?.contentView } private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { - splitDividerCursorKind(at: point) != nil + guard let dividerHit = splitDividerHit(at: point) else { return false } + // Portal host should pass split-divider events through to app layout splits, + // but keep WebKit inspector/internal split dividers interactive. + return !dividerHit.isInHostedContent } static func shouldPassThroughToDragTargets( @@ -261,7 +586,188 @@ final class WindowBrowserHostView: NSView { } } - private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? { + private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? { + let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } + .filter { !$0.isHidden && $0.window != nil && $0.frame.height > 1 } + + for slot in visibleSlots { + let pointInSlot = slot.convert(point, from: self) + guard slot.bounds.contains(pointInSlot), + let hit = hostedInspectorDividerCandidate(in: slot) else { + continue + } + + if hostedInspectorDividerHitRect(for: hit).contains(pointInSlot) { + return hit + } + } + + return nil + } + + private func hostedInspectorDividerCandidate(in slot: WindowBrowserSlotView) -> HostedInspectorDividerHit? { + let inspectorCandidates = Self.visibleDescendants(in: slot) + .filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) } + .sorted { lhs, rhs in + let lhsFrame = slot.convert(lhs.bounds, from: lhs) + let rhsFrame = slot.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(in: slot, startingAt: inspectorCandidate) else { + continue + } + let score = hostedInspectorDividerCandidateScore(candidate) + if score > bestScore { + bestScore = score + bestHit = candidate + } + } + + return bestHit + } + + private func hostedInspectorDividerCandidate( + in slot: WindowBrowserSlotView, + startingAt inspectorLeaf: NSView + ) -> HostedInspectorDividerHit? { + var current: NSView? = inspectorLeaf + var bestHit: HostedInspectorDividerHit? + + while let inspectorView = current, inspectorView !== slot { + 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( + slotView: slot, + containerView: containerView, + pageView: pageView, + inspectorView: inspectorView + ) + } + + current = containerView + } + + return bestHit + } + + private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect { + let slotBounds = hit.slotView.bounds + let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView) + let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView) + let minY = max(slotBounds.minY, min(pageFrame.minY, inspectorFrame.minY)) + let maxY = min(slotBounds.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 hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat { + let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView) + let inspectorFrame = hit.slotView.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 reapplyHostedInspectorDividersIfNeeded(reason: String) { + let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } + .filter { !$0.isHidden && $0.window != nil && $0.frame.height > 1 } + for slot in visibleSlots { + reapplyHostedInspectorDividerIfNeeded(in: slot, reason: reason) + } + } + + private func scheduleHostedInspectorDividerReapply(in slot: WindowBrowserSlotView, reason: String) { + guard slot.preferredHostedInspectorWidth != nil else { return } + DispatchQueue.main.async { [weak self, weak slot] in + guard let self, let slot, slot.isDescendant(of: self) else { return } + self.reapplyHostedInspectorDividerIfNeeded(in: slot, reason: reason) + } + } + + fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) { + guard let preferredWidth = slot.preferredHostedInspectorWidth else { return } + guard let hit = hostedInspectorDividerCandidate(in: slot) else { return } + _ = 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) + } + + hit.slotView.isApplyingHostedInspectorLayout = true + CATransaction.begin() + CATransaction.setDisableActions(true) + hit.pageView.frame = pageFrame + hit.inspectorView.frame = inspectorFrame + CATransaction.commit() + hit.slotView.isApplyingHostedInspectorLayout = false + + hit.pageView.needsLayout = true + hit.inspectorView.needsLayout = true + hit.containerView.needsLayout = true + hit.slotView.needsLayout = true +#if DEBUG + dlog( + "browser.portal.manualInspectorDrag stage=reapply slot=\(browserPortalDebugToken(hit.slotView)) " + + "container=\(browserPortalDebugToken(hit.containerView)) reason=\(reason) " + + "preferredWidth=\(String(format: "%.1f", preferredWidth)) " + + "pageFrame=\(browserPortalDebugFrame(pageFrame)) " + + "inspectorFrame=\(browserPortalDebugFrame(inspectorFrame))" + ) +#endif + return (pageFrame, inspectorFrame) + } + private static func dividerHit( + at windowPoint: NSPoint, + in view: NSView, + hostView: WindowBrowserHostView + ) -> DividerHit? { guard !view.isHidden else { return nil } if let splitView = view as? NSSplitView { @@ -299,21 +805,62 @@ final class WindowBrowserHostView: NSView { } let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion) if expanded.contains(pointInSplit) { - return splitView.isVertical ? .vertical : .horizontal + return DividerHit( + kind: splitView.isVertical ? .vertical : .horizontal, + isInHostedContent: splitView.isDescendant(of: hostView) + ) } } } } for subview in view.subviews.reversed() { - if let kind = dividerCursorKind(at: windowPoint, in: subview) { - return kind + if let hit = dividerHit(at: windowPoint, in: subview, hostView: hostView) { + return hit } } return nil } + private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat { + max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY)) + } + + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + private static func 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 collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) { guard !view.isHidden else { return } @@ -359,6 +906,14 @@ private final class BrowserDropZoneOverlayView: NSView { } } +struct BrowserPortalSearchOverlayConfiguration { + let panelId: UUID + let searchState: BrowserSearchState + let onNext: () -> Void + let onPrevious: () -> Void + let onClose: () -> Void +} + struct BrowserPaneDropContext: Equatable { let workspaceId: UUID let panelId: UUID @@ -419,16 +974,23 @@ enum BrowserPaneDropAction: Equatable { } enum BrowserPaneDropRouting { - static func zone(for location: CGPoint, in size: CGSize) -> DropZone { + private static let padding: CGFloat = 4 + + private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize { + CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight)) + } + + static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone { + let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight) let edgeRatio: CGFloat = 0.25 - let horizontalEdge = max(80, size.width * edgeRatio) - let verticalEdge = max(80, size.height * edgeRatio) + let horizontalEdge = max(80, fullPaneSize.width * edgeRatio) + let verticalEdge = max(80, fullPaneSize.height * edgeRatio) if location.x < horizontalEdge { return .left - } else if location.x > size.width - horizontalEdge { + } else if location.x > fullPaneSize.width - horizontalEdge { return .right - } else if location.y > size.height - verticalEdge { + } else if location.y > fullPaneSize.height - verticalEdge { return .top } else if location.y < verticalEdge { return .bottom @@ -437,6 +999,47 @@ enum BrowserPaneDropRouting { } } + static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect { + let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight) + switch zone { + case .center: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height - padding * 2 + ) + case .left: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width / 2 - padding, + height: fullPaneSize.height - padding * 2 + ) + case .right: + return CGRect( + x: fullPaneSize.width / 2, + y: padding, + width: fullPaneSize.width / 2 - padding, + height: fullPaneSize.height - padding * 2 + ) + case .top: + return CGRect( + x: padding, + y: fullPaneSize.height / 2, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height / 2 - padding + ) + case .bottom: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height / 2 - padding + ) + } + } + static func action( for transfer: BrowserPaneDragTransfer, target: BrowserPaneDropContext, @@ -556,7 +1159,11 @@ final class BrowserPaneDropTargetView: NSView { } let location = convert(sender.draggingLocation, from: nil) - let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size) + let zone = BrowserPaneDropRouting.zone( + for: location, + in: bounds.size, + topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0 + ) guard let action = BrowserPaneDropRouting.action( for: transfer, target: dropContext, @@ -612,7 +1219,11 @@ final class BrowserPaneDropTargetView: NSView { } let location = convert(sender.draggingLocation, from: nil) - let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size) + let zone = BrowserPaneDropRouting.zone( + for: location, + in: bounds.size, + topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0 + ) activeZone = zone slotView?.setPortalDragDropZone(zone) #if DEBUG @@ -669,11 +1280,16 @@ final class WindowBrowserSlotView: NSView { override var isOpaque: Bool { false } private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero) private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero) + private var searchOverlayHostingView: NSHostingView? private var forwardedDropZone: DropZone? private var portalDragDropZone: DropZone? private var displayedDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 private var isRefreshingInteractionLayers = false + private var paneTopChromeHeight: CGFloat = 0 + var preferredHostedInspectorWidth: CGFloat? + var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)? + fileprivate var isApplyingHostedInspectorLayout = false override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -691,7 +1307,6 @@ final class WindowBrowserSlotView: NSView { dropZoneOverlayView.layer?.cornerRadius = 8 dropZoneOverlayView.isHidden = true addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) - addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) } @available(*, unavailable) @@ -703,6 +1318,14 @@ final class WindowBrowserSlotView: NSView { super.layout() paneDropTargetView.frame = bounds applyResolvedDropZoneOverlay() + guard !isApplyingHostedInspectorLayout else { return } + onHostedInspectorLayout?(self) + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + attachDropZoneOverlayIfNeeded() + applyResolvedDropZoneOverlay() } func setDropZoneOverlay(zone: DropZone?) { @@ -719,9 +1342,62 @@ final class WindowBrowserSlotView: NSView { paneDropTargetView.dropContext = context } + func setPaneTopChromeHeight(_ height: CGFloat) { + let resolvedHeight = max(0, height) + guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return } + paneTopChromeHeight = resolvedHeight + applyResolvedDropZoneOverlay() + } + + func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) { + guard let configuration else { + searchOverlayHostingView?.removeFromSuperview() + searchOverlayHostingView = nil + return + } + + let rootView = BrowserSearchOverlay( + panelId: configuration.panelId, + searchState: configuration.searchState, + onNext: configuration.onNext, + onPrevious: configuration.onPrevious, + onClose: configuration.onClose + ) + + if let overlay = searchOverlayHostingView { + overlay.rootView = rootView + if overlay.superview !== self { + overlay.removeFromSuperview() + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + return + } + + let overlay = NSHostingView(rootView: rootView) + overlay.translatesAutoresizingMaskIntoConstraints = false + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + searchOverlayHostingView = overlay + } + + func effectivePaneTopChromeHeight() -> CGFloat { + paneTopChromeHeight + } + override func didAddSubview(_ subview: NSView) { super.didAddSubview(subview) - guard subview !== paneDropTargetView, subview !== dropZoneOverlayView else { return } + guard subview !== paneDropTargetView else { return } bringInteractionLayersToFrontIfNeeded() } @@ -729,6 +1405,17 @@ final class WindowBrowserSlotView: NSView { portalDragDropZone ?? forwardedDropZone } + private func overlayContainerView() -> NSView { + superview ?? self + } + + private func attachDropZoneOverlayIfNeeded() { + let container = overlayContainerView() + guard dropZoneOverlayView.superview !== container else { return } + dropZoneOverlayView.removeFromSuperview() + container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) + } + private func applyResolvedDropZoneOverlay() { let resolvedZone = activeDropZone if resolvedZone != nil, (bounds.width <= 2 || bounds.height <= 2) { @@ -764,6 +1451,7 @@ final class WindowBrowserSlotView: NSView { } return } + attachDropZoneOverlayIfNeeded() let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size) let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame) @@ -805,7 +1493,6 @@ final class WindowBrowserSlotView: NSView { private func interactionLayerPriority(of view: NSView) -> Int { if view === paneDropTargetView { return 1 } - if view === dropZoneOverlayView { return 2 } return 0 } @@ -817,8 +1504,11 @@ final class WindowBrowserSlotView: NSView { if paneDropTargetView.superview !== self { addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) } - if dropZoneOverlayView.superview !== self { - addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) + let overlayContainer = overlayContainerView() + if dropZoneOverlayView.superview !== overlayContainer { + attachDropZoneOverlayIfNeeded() + } else if overlayContainer.subviews.last !== dropZoneOverlayView { + overlayContainer.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) } let context = Unmanaged.passUnretained(self).toOpaque() @@ -841,19 +1531,13 @@ final class WindowBrowserSlotView: NSView { } private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { - let padding: CGFloat = 4 - switch zone { - case .center: - return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2) - case .left: - return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) - case .right: - return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) - case .top: - return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding) - case .bottom: - return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding) - } + let localFrame = BrowserPaneDropRouting.overlayFrame( + for: zone, + in: size, + topChromeHeight: paneTopChromeHeight + ) + guard let superview else { return localFrame } + return superview.convert(localFrame, from: self) } private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool { @@ -884,6 +1568,9 @@ final class WindowBrowserPortal: NSObject { var zPriority: Int var dropZone: DropZone? var paneDropContext: BrowserPaneDropContext? + var searchOverlay: BrowserPortalSearchOverlayConfiguration? + var paneTopChromeHeight: CGFloat + var transientRecoveryReason: String? var transientRecoveryRetriesRemaining: Int } @@ -1142,10 +1829,14 @@ final class WindowBrowserPortal: NSObject { private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView { if let existing = entry.containerView { existing.setPaneDropContext(entry.paneDropContext) + existing.setSearchOverlay(entry.searchOverlay) + existing.setPaneTopChromeHeight(entry.paneTopChromeHeight) return existing } let created = WindowBrowserSlotView(frame: .zero) created.setPaneDropContext(entry.paneDropContext) + created.setSearchOverlay(entry.searchOverlay) + created.setPaneTopChromeHeight(entry.paneTopChromeHeight) #if DEBUG dlog( "browser.portal.container.create web=\(browserPortalDebugToken(webView)) " + @@ -1267,6 +1958,14 @@ final class WindowBrowserPortal: NSObject { entriesByWebViewId[webViewId] = entry } + func hideWebView(withId webViewId: ObjectIdentifier, source: String = "externalHide") { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.visibleInUI = false + entry.zPriority = 0 + entriesByWebViewId[webViewId] = entry + synchronizeWebView(withId: webViewId, source: source) + } + func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) { guard var entry = entriesByWebViewId[webViewId] else { return } entry.dropZone = zone @@ -1281,6 +1980,25 @@ final class WindowBrowserPortal: NSObject { entry.containerView?.setPaneDropContext(context) } + func updateSearchOverlay( + forWebViewId webViewId: ObjectIdentifier, + configuration: BrowserPortalSearchOverlayConfiguration? + ) { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.searchOverlay = configuration + entriesByWebViewId[webViewId] = entry + entry.containerView?.setSearchOverlay(configuration) + } + + func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) { + guard var entry = entriesByWebViewId[webViewId] else { return } + let resolvedHeight = max(0, height) + guard abs(entry.paneTopChromeHeight - resolvedHeight) > 0.5 else { return } + entry.paneTopChromeHeight = resolvedHeight + entriesByWebViewId[webViewId] = entry + entry.containerView?.setPaneTopChromeHeight(resolvedHeight) + } + func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -1296,6 +2014,9 @@ final class WindowBrowserPortal: NSObject { zPriority: 0, dropZone: nil, paneDropContext: nil, + searchOverlay: nil, + paneTopChromeHeight: 0, + transientRecoveryReason: nil, transientRecoveryRetriesRemaining: 0 ), webView: webView @@ -1329,6 +2050,9 @@ final class WindowBrowserPortal: NSObject { zPriority: zPriority, dropZone: previousEntry?.dropZone, paneDropContext: previousEntry?.paneDropContext, + searchOverlay: previousEntry?.searchOverlay, + paneTopChromeHeight: previousEntry?.paneTopChromeHeight ?? 0, + transientRecoveryReason: previousEntry?.transientRecoveryReason, transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0 ) @@ -1446,7 +2170,8 @@ final class WindowBrowserPortal: NSObject { } private func resetTransientRecoveryRetryIfNeeded(forWebViewId webViewId: ObjectIdentifier, entry: inout Entry) { - guard entry.transientRecoveryRetriesRemaining != 0 else { return } + guard entry.transientRecoveryRetriesRemaining != 0 || entry.transientRecoveryReason != nil else { return } + entry.transientRecoveryReason = nil entry.transientRecoveryRetriesRemaining = 0 entriesByWebViewId[webViewId] = entry } @@ -1457,9 +2182,18 @@ final class WindowBrowserPortal: NSObject { webView: WKWebView, reason: String ) -> Bool { - if entry.transientRecoveryRetriesRemaining == 0 { + if entry.transientRecoveryReason != reason { + entry.transientRecoveryReason = reason entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget } +#if DEBUG + if entry.transientRecoveryRetriesRemaining <= 0 { + dlog( + "browser.portal.sync.deferRecover.skip web=\(browserPortalDebugToken(webView)) " + + "reason=\(reason) exhausted=1" + ) + } +#endif guard entry.transientRecoveryRetriesRemaining > 0 else { return false } entry.transientRecoveryRetriesRemaining -= 1 @@ -1494,15 +2228,24 @@ final class WindowBrowserPortal: NSObject { } return } + func scheduleTransientDetachRecovery(reason: String) -> Bool { + guard entry.visibleInUI else { return false } + return scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: reason + ) + } guard let anchorView = entry.anchorView, let window else { - if entry.visibleInUI { - _ = scheduleTransientRecoveryRetryIfNeeded( - forWebViewId: webViewId, - entry: &entry, - webView: webView, - reason: "missingAnchorOrWindow" - ) - } else { + if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") { + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) + containerView.setDropZoneOverlay(zone: nil) + containerView.isHidden = true + return + } + if !entry.visibleInUI { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } #if DEBUG @@ -1513,11 +2256,20 @@ final class WindowBrowserPortal: NSObject { ) } #endif + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true return } guard anchorView.window === window else { + if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") { + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) + containerView.setDropZoneOverlay(zone: nil) + containerView.isHidden = true + return + } #if DEBUG if !containerView.isHidden { dlog( @@ -1527,16 +2279,11 @@ final class WindowBrowserPortal: NSObject { ) } #endif - if entry.visibleInUI { - _ = scheduleTransientRecoveryRetryIfNeeded( - forWebViewId: webViewId, - entry: &entry, - webView: webView, - reason: "anchorWindowMismatch" - ) - } else { + if !entry.visibleInUI { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true return @@ -1617,6 +2364,7 @@ final class WindowBrowserPortal: NSObject { } else { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } + containerView.setSearchOverlay(nil) containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true if entry.visibleInUI { @@ -1629,6 +2377,7 @@ final class WindowBrowserPortal: NSObject { } else { scheduleDeferredFullSynchronizeAll() } + containerView.setPaneTopChromeHeight(0) return } let oldFrame = containerView.frame @@ -1788,6 +2537,8 @@ final class WindowBrowserPortal: NSObject { #endif containerView.isHidden = false } + containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight) + containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay) containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone) if revealedForDisplay { refreshReasons.append("reveal") @@ -1805,6 +2556,7 @@ final class WindowBrowserPortal: NSObject { reason: "\(source):" + refreshReasons.joined(separator: ",") ) } + hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync") #if DEBUG dlog( "browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " + @@ -1997,6 +2749,13 @@ enum BrowserWindowPortalRegistry { portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) } + static func hide(webView: WKWebView, source: String = "externalHide") { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.hideWebView(withId: webViewId, source: source) + } + static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId[webViewId], @@ -2011,6 +2770,23 @@ enum BrowserWindowPortalRegistry { portal.updatePaneDropContext(forWebViewId: webViewId, context: context) } + static func updateSearchOverlay( + for webView: WKWebView, + configuration: BrowserPortalSearchOverlayConfiguration? + ) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration) + } + + static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updatePaneTopChromeHeight(forWebViewId: webViewId, height: height) + } + static func detach(webView: WKWebView) { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 00e57e88..f17f60de 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import ImageIO import SwiftUI import ObjectiveC import UniformTypeIdentifiers @@ -1111,6 +1112,23 @@ private final class WindowCommandPaletteOverlayController: NSObject { containerView.isHidden = true } } + + func underlyingResponder(atWindowPoint windowPoint: NSPoint) -> NSResponder? { + guard let window, + let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + + let previousCapturesMouseEvents = containerView.capturesMouseEvents + containerView.capturesMouseEvents = false + defer { + containerView.capturesMouseEvents = previousCapturesMouseEvents + } + + let pointInTheme = themeFrame.convert(windowPoint, from: nil) + return themeFrame.hitTest(pointInTheme) + } } @MainActor @@ -1123,6 +1141,40 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind return controller } +private func commandPaletteOwningWebView(for responder: NSResponder?) -> WKWebView? { + guard let responder else { return nil } + + if let webView = responder as? WKWebView { + return webView + } + + if let view = responder as? NSView { + var current: NSView? = view + while let candidate = current { + if let webView = candidate as? WKWebView { + return webView + } + current = candidate.superview + } + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let webView = commandPaletteOwningWebView(for: delegateView) { + return webView + } + + var currentResponder = responder.nextResponder + while let next = currentResponder { + if let webView = commandPaletteOwningWebView(for: next) { + return webView + } + currentResponder = next.nextResponder + } + + return nil +} + enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 @@ -1274,6 +1326,7 @@ struct ContentView: View { @State private var commandPalettePendingActivation: CommandPalettePendingActivation? @State private var commandPaletteResultsRevision: UInt64 = 0 @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] + @State private var isFeedbackComposerPresented = false @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) @@ -1822,6 +1875,7 @@ struct ContentView: View { private var sidebarView: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, + onSendFeedback: presentFeedbackComposer, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex @@ -1844,6 +1898,7 @@ struct ContentView: View { ForEach(mountedWorkspaces) { tab in let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id + let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id) // Keep the retiring workspace visible during handoff, but never input-active. // Allowing both selected+retiring workspaces to be input-active lets the // old workspace steal first responder (notably with WKWebView), which can @@ -1870,6 +1925,9 @@ struct ContentView: View { .allowsHitTesting(isSelectedWorkspace) .accessibilityHidden(!isVisible) .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) + .task(id: shouldPrimeInBackground ? tab.id : nil) { + await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id) + } } } .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0) @@ -2214,6 +2272,10 @@ struct ContentView: View { reconcileMountedWorkspaceIds() }) + view = AnyView(view.onReceive(tabManager.$pendingBackgroundWorkspaceLoadIds) { _ in + reconcileMountedWorkspaceIds() + }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } @@ -2274,6 +2336,7 @@ struct ContentView: View { if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) { self.previousSelectedWorkspaceId = tabManager.selectedTabId } + tabManager.pruneBackgroundWorkspaceLoads(existingIds: existingIds) reconcileMountedWorkspaceIds(tabs: tabs) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { @@ -2418,6 +2481,17 @@ struct ContentView: View { _ = handleCommandPaletteRenameDeleteBackward(modifiers: []) }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .feedbackComposerRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + presentFeedbackComposer() + }) + view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in MainActor.assumeIsolated { let overlayController = commandPaletteWindowOverlayController(for: window) @@ -2485,6 +2559,9 @@ struct ContentView: View { }) view = AnyView(view.ignoresSafeArea()) + view = AnyView(view.sheet(isPresented: $isFeedbackComposerPresented) { + SidebarFeedbackComposerSheet() + }) view = AnyView(view.onDisappear { removeSidebarResizerPointerMonitor() @@ -2578,9 +2655,10 @@ struct ContentView: View { let currentTabs = tabs ?? tabManager.tabs let orderedTabIds = currentTabs.map { $0.id } let effectiveSelectedId = selectedId ?? tabManager.selectedTabId - let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] + let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] + let pinnedIds = handoffPinnedIds.union(tabManager.pendingBackgroundWorkspaceLoadIds) let isCycleHot = tabManager.isWorkspaceCycleHot - let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty + let shouldKeepHandoffPair = isCycleHot && !handoffPinnedIds.isEmpty let baseMaxMounted = shouldKeepHandoffPair ? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle : WorkspaceMountPolicy.maxMountedWorkspaces @@ -2617,6 +2695,81 @@ struct ContentView: View { #endif } + private enum BackgroundWorkspacePrimeState { + case pending + case completed(reason: String) + } + + private enum BackgroundWorkspacePrimePolicy { + static let timeoutSeconds: TimeInterval = 2.0 + static let pollIntervalNanoseconds: UInt64 = 50_000_000 + } + + private func primeBackgroundWorkspaceIfNeeded(workspaceId: UUID) async { + let shouldPrime = await MainActor.run { + tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) + } + guard shouldPrime else { return } + +#if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))") +#endif + + let timeout = Date().addingTimeInterval(BackgroundWorkspacePrimePolicy.timeoutSeconds) + while !Task.isCancelled { + let state = await MainActor.run { + stepBackgroundWorkspacePrime(workspaceId: workspaceId) + } + switch state { + case .pending: + if Date() < timeout { + try? await Task.sleep(nanoseconds: BackgroundWorkspacePrimePolicy.pollIntervalNanoseconds) + continue + } + await MainActor.run { + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 + dlog( + "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + + "reason=timeout ms=\(String(format: "%.2f", elapsedMs))" + ) +#endif + return + case .completed(let reason): +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 + dlog( + "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + + "reason=\(reason) ms=\(String(format: "%.2f", elapsedMs))" + ) +#endif + return + } + } + } + + @MainActor + private func stepBackgroundWorkspacePrime(workspaceId: UUID) -> BackgroundWorkspacePrimeState { + guard tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { + return .completed(reason: "already_cleared") + } + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + return .completed(reason: "workspace_removed") + } + + workspace.requestBackgroundTerminalSurfaceStartIfNeeded() + guard workspace.hasLoadedTerminalSurface() else { + return .pending + } + + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + return .completed(reason: "surface_ready") + } + private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs @@ -2704,13 +2857,14 @@ struct ContentView: View { workspaceHandoffFallbackTask = nil let retiring = retiringWorkspaceId - // Hide terminal portal views for the retiring workspace BEFORE clearing + // Hide portal-hosted views for the retiring workspace BEFORE clearing // retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts // the workspace — but dismantleNSView intentionally doesn't hide portal views - // (to avoid blackouts during transient bonsplit dismantles). Hiding here - // prevents stale portal-hosted terminals from covering browser panes. + // during transient rebuilds. Hiding here prevents stale terminal/browser + // portals from covering the newly selected workspace. if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { workspace.hideAllTerminalPortalViews() + workspace.hideAllBrowserPortalViews() } retiringWorkspaceId = nil @@ -2736,9 +2890,18 @@ struct ContentView: View { Color.clear .ignoresSafeArea() .contentShape(Rectangle()) - .onTapGesture { - dismissCommandPalette() - } + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + handleCommandPaletteBackdropClick(atContentPoint: value.location) + } + ) + + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .allowsHitTesting(false) + .accessibilityIdentifier("CommandPaletteBackdrop") VStack(spacing: 0) { switch commandPaletteMode { @@ -2787,6 +2950,7 @@ struct ContentView: View { .font(.system(size: 13, weight: .regular)) .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) .focused($isCommandPaletteSearchFocused) + .accessibilityIdentifier("CommandPaletteSearchField") .onSubmit { runSelectedCommandPaletteResult() } @@ -2951,6 +3115,7 @@ struct ContentView: View { .font(.system(size: 13, weight: .regular)) .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) .focused($isCommandPaletteRenameFocused) + .accessibilityIdentifier("CommandPaletteRenameField") .backport.onKeyPress(.delete) { modifiers in handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) } @@ -4917,6 +5082,12 @@ struct ContentView: View { beginRenameWorkspaceFlow() } + private func presentFeedbackComposer() { + DispatchQueue.main.async { + isFeedbackComposerPresented = true + } + } + static func shouldHandleCommandPaletteRequest( observedWindow: NSWindow?, requestedWindow: NSWindow?, @@ -5018,7 +5189,14 @@ struct ContentView: View { } private func dismissCommandPalette(restoreFocus: Bool = true) { - let focusTarget = commandPaletteRestoreFocusTarget + dismissCommandPalette(restoreFocus: restoreFocus, preferredFocusTarget: nil) + } + + private func dismissCommandPalette( + restoreFocus: Bool, + preferredFocusTarget: CommandPaletteRestoreFocusTarget? + ) { + let focusTarget = preferredFocusTarget ?? commandPaletteRestoreFocusTarget cancelCommandPaletteSearch() commandPaletteSearchRequestID &+= 1 isCommandPalettePresented = false @@ -5051,6 +5229,117 @@ struct ContentView: View { restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6) } + private func handleCommandPaletteBackdropClick(atContentPoint contentPoint: CGPoint) { + let clickedFocusTarget = commandPaletteBackdropFocusTarget(atContentPoint: contentPoint) +#if DEBUG + if let clickedFocusTarget { + dlog( + "palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " + + "workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")" + ) + } else { + dlog("palette.dismiss.backdrop focusTarget=nil") + } +#endif + dismissCommandPalette(restoreFocus: true, preferredFocusTarget: clickedFocusTarget) + } + + private func commandPaletteBackdropFocusTarget(atContentPoint contentPoint: CGPoint) -> CommandPaletteRestoreFocusTarget? { + guard let window = observedWindow, + let contentView = window.contentView else { + return nil + } + + let nsContentPoint = NSPoint(x: contentPoint.x, y: contentPoint.y) + let windowPoint = contentView.convert(nsContentPoint, to: nil) + return commandPaletteBackdropFocusTarget(atWindowPoint: windowPoint, in: window) + } + + private func commandPaletteBackdropFocusTarget( + atWindowPoint windowPoint: NSPoint, + in window: NSWindow + ) -> CommandPaletteRestoreFocusTarget? { + let overlayController = commandPaletteWindowOverlayController(for: window) + if let responder = overlayController.underlyingResponder(atWindowPoint: windowPoint), + let target = commandPaletteBackdropFocusTarget(for: responder) { + return target + } + + if let webView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window), + let target = commandPaletteBrowserFocusTarget(for: webView) { + return target + } + + if let terminalView = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window), + let workspaceId = terminalView.tabId, + let panelId = terminalView.terminalSurface?.id, + tabManager.tabs.contains(where: { $0.id == workspaceId }) { + return CommandPaletteRestoreFocusTarget( + workspaceId: workspaceId, + panelId: panelId, + intent: .panel + ) + } + + return nil + } + + private func commandPaletteBackdropFocusTarget(for responder: NSResponder) -> CommandPaletteRestoreFocusTarget? { + if let terminalView = cmuxOwningGhosttyView(for: responder), + let workspaceId = terminalView.tabId, + let panelId = terminalView.terminalSurface?.id, + tabManager.tabs.contains(where: { $0.id == workspaceId }) { + return CommandPaletteRestoreFocusTarget( + workspaceId: workspaceId, + panelId: panelId, + intent: .panel + ) + } + + if let webView = commandPaletteOwningWebView(for: responder), + let target = commandPaletteBrowserFocusTarget(for: webView) { + return target + } + + return nil + } + + private func commandPaletteBrowserFocusTarget(for webView: WKWebView) -> CommandPaletteRestoreFocusTarget? { + if let selectedWorkspace = tabManager.selectedWorkspace, + let target = commandPaletteBrowserFocusTarget(in: selectedWorkspace, for: webView) { + return target + } + + let selectedWorkspaceId = tabManager.selectedTabId + for workspace in tabManager.tabs where workspace.id != selectedWorkspaceId { + if let target = commandPaletteBrowserFocusTarget(in: workspace, for: webView) { + return target + } + } + + return nil + } + + private func commandPaletteBrowserFocusTarget( + in workspace: Workspace, + for webView: WKWebView + ) -> CommandPaletteRestoreFocusTarget? { + for (panelId, panel) in workspace.panels { + guard let browserPanel = panel as? BrowserPanel, + browserPanel.webView === webView else { + continue + } + + return CommandPaletteRestoreFocusTarget( + workspaceId: workspace.id, + panelId: panelId, + intent: .panel + ) + } + + return nil + } + private func restoreCommandPaletteFocus( target: CommandPaletteRestoreFocusTarget, attemptsRemaining: Int @@ -6147,6 +6436,7 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @@ -6220,15 +6510,8 @@ struct VerticalTabsSidebar: View { .background(Color.clear) .modifier(ClearScrollBackground()) } -#if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel) + SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) .frame(maxWidth: .infinity, alignment: .leading) -#else - UpdatePill(model: updateViewModel) - .padding(.horizontal, 10) - .padding(.bottom, 10) - .frame(maxWidth: .infinity, alignment: .leading) -#endif } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() @@ -6376,6 +6659,343 @@ enum ShortcutHintDebugSettings { } } +enum DevBuildBannerDebugSettings { + static let sidebarBannerVisibleKey = "showSidebarDevBuildBanner" + static let defaultShowSidebarBanner = true + + static func showSidebarBanner(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: sidebarBannerVisibleKey) != nil else { + return defaultShowSidebarBanner + } + return defaults.bool(forKey: sidebarBannerVisibleKey) + } +} + +private enum FeedbackComposerSettings { + static let storedEmailKey = "sidebarHelpFeedbackEmail" + static let endpointEnvironmentKey = "CMUX_FEEDBACK_API_URL" + static let defaultEndpoint = "https://www.cmux.dev/api/feedback" + static let foundersEmail = "founders@manaflow.com" + static let maxMessageLength = 4_000 + static let maxAttachmentCount = 10 + // Keep the multipart body below Vercel's 4.5 MB request limit. + static let maxTotalAttachmentBytes = 4 * 1_024 * 1_024 + static let targetTotalAttachmentUploadBytes = 3_500_000 + + static func endpointURL() -> URL? { + let env = ProcessInfo.processInfo.environment + if let override = env[endpointEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return URL(string: override) + } + return URL(string: defaultEndpoint) + } +} + +private struct FeedbackComposerAttachment: Identifiable { + let id = UUID() + let url: URL + let fileName: String + let fileSize: Int64 + let mimeType: String + + var standardizedPath: String { + url.standardizedFileURL.path + } + + var displaySize: String { + ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file) + } + + init(url: URL) throws { + let resourceValues = try url.resourceValues(forKeys: [ + .contentTypeKey, + .fileSizeKey, + .isRegularFileKey, + .nameKey, + ]) + guard resourceValues.isRegularFile != false else { + throw CocoaError(.fileReadUnknown) + } + + self.url = url + self.fileName = resourceValues.name ?? url.lastPathComponent + self.fileSize = Int64(resourceValues.fileSize ?? 0) + self.mimeType = resourceValues.contentType?.preferredMIMEType ?? "application/octet-stream" + } +} + +private struct PreparedFeedbackComposerAttachment { + let fileName: String + let mimeType: String + let data: Data +} + +private struct FeedbackComposerAppMetadata { + let appVersion: String + let appBuild: String + let appCommit: String + let bundleIdentifier: String + let osVersion: String + let localeIdentifier: String + + static var current: FeedbackComposerAppMetadata { + let infoDictionary = Bundle.main.infoDictionary ?? [:] + let env = ProcessInfo.processInfo.environment + let commit = (infoDictionary["CMUXCommit"] as? String).flatMap { value in + value.isEmpty ? nil : value + } ?? env["CMUX_COMMIT"] + + return FeedbackComposerAppMetadata( + appVersion: infoDictionary["CFBundleShortVersionString"] as? String ?? "", + appBuild: infoDictionary["CFBundleVersion"] as? String ?? "", + appCommit: commit ?? "", + bundleIdentifier: Bundle.main.bundleIdentifier ?? "", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier + ) + } +} + +private enum FeedbackComposerSubmissionError: Error { + case invalidEndpoint + case invalidResponse + case rejected(statusCode: Int) + case attachmentReadFailed + case attachmentPreparationFailed + case transport(URLError) +} + +private enum FeedbackComposerClient { + private static let passthroughAttachmentMIMETypes: Set = [ + "image/gif", + "image/heic", + "image/heif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", + ] + private static let optimizedAttachmentDimensions: [Int] = [2800, 2400, 2000, 1600, 1280, 1024, 768, 640, 512] + private static let optimizedAttachmentQualities: [CGFloat] = [0.82, 0.72, 0.62, 0.52, 0.42, 0.32] + private static let optimizedAttachmentMIMEType = "image/jpeg" + + static func submit( + email: String, + message: String, + attachments: [FeedbackComposerAttachment] + ) async throws { + guard let endpointURL = FeedbackComposerSettings.endpointURL() else { + throw FeedbackComposerSubmissionError.invalidEndpoint + } + + let metadata = FeedbackComposerAppMetadata.current + let boundary = "Boundary-\(UUID().uuidString)" + let preparedAttachments = try prepareAttachmentsForUpload(attachments) + + var request = URLRequest(url: endpointURL) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + var body = Data() + appendField("email", value: email, to: &body, boundary: boundary) + appendField("message", value: message, to: &body, boundary: boundary) + appendField("appVersion", value: metadata.appVersion, to: &body, boundary: boundary) + appendField("appBuild", value: metadata.appBuild, to: &body, boundary: boundary) + appendField("appCommit", value: metadata.appCommit, to: &body, boundary: boundary) + appendField("bundleIdentifier", value: metadata.bundleIdentifier, to: &body, boundary: boundary) + appendField("osVersion", value: metadata.osVersion, to: &body, boundary: boundary) + appendField("locale", value: metadata.localeIdentifier, to: &body, boundary: boundary) + + for attachment in preparedAttachments { + appendFile( + named: "attachments", + attachment: attachment, + to: &body, + boundary: boundary + ) + } + + body.append(Data("--\(boundary)--\r\n".utf8)) + request.httpBody = body + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError { + throw FeedbackComposerSubmissionError.transport(error) + } catch { + throw FeedbackComposerSubmissionError.invalidResponse + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw FeedbackComposerSubmissionError.invalidResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + if let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorMessage = payload["error"] as? String, + errorMessage.isEmpty == false { + NSLog("feedback.submit.rejected status=%@ error=%@", String(httpResponse.statusCode), errorMessage) + } + throw FeedbackComposerSubmissionError.rejected(statusCode: httpResponse.statusCode) + } + } + + private static func appendField( + _ name: String, + value: String, + to body: inout Data, + boundary: String + ) { + body.append(Data("--\(boundary)\r\n".utf8)) + body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) + body.append(Data(value.utf8)) + body.append(Data("\r\n".utf8)) + } + + private static func prepareAttachmentsForUpload( + _ attachments: [FeedbackComposerAttachment] + ) throws -> [PreparedFeedbackComposerAttachment] { + guard attachments.isEmpty == false else { return [] } + + struct IndexedAttachment { + let index: Int + let attachment: FeedbackComposerAttachment + } + + let sortedAttachments = attachments.enumerated() + .map { IndexedAttachment(index: $0.offset, attachment: $0.element) } + .sorted { lhs, rhs in + lhs.attachment.fileSize > rhs.attachment.fileSize + } + + var preparedByIndex: [Int: PreparedFeedbackComposerAttachment] = [:] + var remainingBudget = FeedbackComposerSettings.targetTotalAttachmentUploadBytes + var remainingCount = sortedAttachments.count + + for item in sortedAttachments { + let perAttachmentBudget = max(1, remainingBudget / max(remainingCount, 1)) + let preparedAttachment = try prepareAttachmentForUpload( + item.attachment, + maximumByteCount: perAttachmentBudget + ) + preparedByIndex[item.index] = preparedAttachment + remainingBudget -= preparedAttachment.data.count + remainingCount -= 1 + } + + let preparedAttachments = attachments.indices.compactMap { preparedByIndex[$0] } + let totalBytes = preparedAttachments.reduce(0) { $0 + $1.data.count } + guard totalBytes <= FeedbackComposerSettings.targetTotalAttachmentUploadBytes else { + throw FeedbackComposerSubmissionError.attachmentPreparationFailed + } + return preparedAttachments + } + + private static func prepareAttachmentForUpload( + _ attachment: FeedbackComposerAttachment, + maximumByteCount: Int + ) throws -> PreparedFeedbackComposerAttachment { + if attachment.fileSize > 0, + attachment.fileSize <= Int64(maximumByteCount), + passthroughAttachmentMIMETypes.contains(attachment.mimeType), + let fileData = try? Data(contentsOf: attachment.url, options: .mappedIfSafe) { + return PreparedFeedbackComposerAttachment( + fileName: attachment.fileName, + mimeType: attachment.mimeType, + data: fileData + ) + } + + guard let imageSource = CGImageSourceCreateWithURL(attachment.url as CFURL, nil) else { + throw FeedbackComposerSubmissionError.attachmentReadFailed + } + + for maxPixelDimension in optimizedAttachmentDimensions { + guard let cgImage = downsampledImage( + from: imageSource, + maxPixelDimension: maxPixelDimension + ) else { continue } + + for compressionQuality in optimizedAttachmentQualities { + guard let jpegData = jpegData( + from: cgImage, + compressionQuality: compressionQuality + ) else { continue } + guard jpegData.count <= maximumByteCount else { continue } + + return PreparedFeedbackComposerAttachment( + fileName: optimizedFileName(for: attachment), + mimeType: optimizedAttachmentMIMEType, + data: jpegData + ) + } + } + + throw FeedbackComposerSubmissionError.attachmentPreparationFailed + } + + private static func downsampledImage( + from imageSource: CGImageSource, + maxPixelDimension: Int + ) -> CGImage? { + CGImageSourceCreateThumbnailAtIndex( + imageSource, + 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceThumbnailMaxPixelSize: maxPixelDimension, + ] as CFDictionary + ) + } + + private static func jpegData( + from image: CGImage, + compressionQuality: CGFloat + ) -> Data? { + let bitmap = NSBitmapImageRep(cgImage: image) + return bitmap.representation( + using: .jpeg, + properties: [ + .compressionFactor: compressionQuality, + ] + ) + } + + private static func optimizedFileName( + for attachment: FeedbackComposerAttachment + ) -> String { + let baseName = (attachment.fileName as NSString).deletingPathExtension + return "\(baseName.isEmpty ? "feedback-image" : baseName).jpg" + } + + private static func appendFile( + named fieldName: String, + attachment: PreparedFeedbackComposerAttachment, + to body: inout Data, + boundary: String + ) { + let sanitizedFileName = attachment.fileName.replacingOccurrences(of: "\"", with: "") + + body.append(Data("--\(boundary)\r\n".utf8)) + body.append( + Data( + "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(sanitizedFileName)\"\r\n".utf8 + ) + ) + body.append(Data("Content-Type: \(attachment.mimeType)\r\n\r\n".utf8)) + body.append(attachment.data) + body.append(Data("\r\n".utf8)) + } +} + enum SidebarDragLifecycleNotification { static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") @@ -6750,19 +7370,862 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } +private struct SidebarFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { +#if DEBUG + SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) +#else + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) +#endif + } +} + +private struct SidebarFooterButtons: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { + HStack(spacing: 4) { + SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + UpdatePill(model: updateViewModel) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct FeedbackComposerMessageEditor: NSViewRepresentable { + @Binding var text: String + let placeholder: String + let accessibilityLabel: String + let accessibilityIdentifier: String + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> FeedbackComposerMessageEditorView { + let view = FeedbackComposerMessageEditorView() + view.placeholder = placeholder + view.textView.string = text + view.textView.delegate = context.coordinator + view.textView.setAccessibilityLabel(accessibilityLabel) + view.textView.setAccessibilityIdentifier(accessibilityIdentifier) + view.setAccessibilityIdentifier(accessibilityIdentifier) + return view + } + + func updateNSView(_ nsView: FeedbackComposerMessageEditorView, context: Context) { + if nsView.textView.string != text { + nsView.textView.string = text + } + nsView.placeholder = placeholder + nsView.textView.setAccessibilityLabel(accessibilityLabel) + nsView.textView.setAccessibilityIdentifier(accessibilityIdentifier) + nsView.setAccessibilityIdentifier(accessibilityIdentifier) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: FeedbackComposerMessageEditor + + init(parent: FeedbackComposerMessageEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + } + } +} + +private final class FeedbackComposerPassthroughLabel: NSTextField { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +private final class FeedbackComposerMessageScrollView: NSScrollView { + weak var focusTextView: NSTextView? + + override func mouseDown(with event: NSEvent) { + if let focusTextView { + _ = window?.makeFirstResponder(focusTextView) + } + super.mouseDown(with: event) + } +} + +private final class FeedbackComposerMessageEditorView: NSView { + private static let textInset = NSSize(width: 10, height: 10) + + let scrollView = FeedbackComposerMessageScrollView() + let textView = NSTextView() + private let placeholderField = FeedbackComposerPassthroughLabel(labelWithString: "") + + var placeholder: String = "" { + didSet { + placeholderField.stringValue = placeholder + updatePlaceholderVisibility() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + wantsLayer = true + layer?.cornerRadius = 8 + layer?.borderWidth = 1 + layer?.borderColor = NSColor.separatorColor.cgColor + layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.hasVerticalScroller = true + scrollView.focusTextView = textView + + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.font = .systemFont(ofSize: 12) + textView.textColor = .labelColor + textView.insertionPointColor = .labelColor + textView.textContainerInset = Self.textInset + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + + scrollView.documentView = textView + addSubview(scrollView) + + placeholderField.translatesAutoresizingMaskIntoConstraints = false + placeholderField.font = .systemFont(ofSize: 12) + placeholderField.textColor = .secondaryLabelColor + placeholderField.lineBreakMode = .byWordWrapping + placeholderField.maximumNumberOfLines = 0 + scrollView.contentView.addSubview(placeholderField) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange(_:)), + name: NSText.didChangeNotification, + object: textView + ) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + + placeholderField.topAnchor.constraint( + equalTo: scrollView.contentView.topAnchor, + constant: Self.textInset.height + ), + placeholderField.leadingAnchor.constraint( + equalTo: scrollView.contentView.leadingAnchor, + constant: Self.textInset.width + ), + placeholderField.trailingAnchor.constraint( + lessThanOrEqualTo: scrollView.contentView.trailingAnchor, + constant: -Self.textInset.width + ), + ]) + + updatePlaceholderVisibility() + } + + override func layout() { + super.layout() + syncTextViewFrameToContentSize() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc + private func textDidChange(_ notification: Notification) { + updatePlaceholderVisibility() + } + + private func updatePlaceholderVisibility() { + placeholderField.isHidden = textView.string.isEmpty == false + } + + private func syncTextViewFrameToContentSize() { + let contentSize = scrollView.contentSize + guard contentSize.width > 0, contentSize.height > 0 else { return } + + textView.minSize = NSSize(width: 0, height: contentSize.height) + textView.textContainer?.containerSize = NSSize( + width: contentSize.width, + height: CGFloat.greatestFiniteMagnitude + ) + + let targetSize = NSSize( + width: contentSize.width, + height: max(textView.frame.height, contentSize.height) + ) + if textView.frame.size != targetSize { + textView.frame = NSRect(origin: .zero, size: targetSize) + } + } +} + +private enum SidebarHelpMenuAction { + case keyboardShortcuts + case docs + case changelog + case github + case githubIssues + case checkForUpdates + case sendFeedback +} + +private struct SidebarFeedbackComposerSheet: View { + @AppStorage(FeedbackComposerSettings.storedEmailKey) private var email = "" + @Environment(\.dismiss) private var dismiss + + @State private var message = "" + @State private var attachments: [FeedbackComposerAttachment] = [] + @State private var isSubmitting = false + @State private var submissionErrorMessage: String? + @State private var didSend = false + + private var trimmedMessage: String { + message.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + isValidEmail(email) && + !trimmedMessage.isEmpty && + message.count <= FeedbackComposerSettings.maxMessageLength && + !isSubmitting && + !didSend + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: "sidebar.help.feedback.title", defaultValue: "Send Feedback")) + .font(.title3.weight(.semibold)) + + if didSend { + successView + } else { + formView + } + } + .padding(20) + .frame(width: 520) + .accessibilityIdentifier("SidebarFeedbackDialog") + } + + private var successView: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "sidebar.help.feedback.successTitle", defaultValue: "Thanks for the feedback.")) + .font(.headline) + Text( + String( + localized: "sidebar.help.feedback.successBody", + defaultValue: "You can also reach us at founders@manaflow.com." + ) + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + HStack { + Spacer() + Button(String(localized: "sidebar.help.feedback.done", defaultValue: "Done")) { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + } + + private var formView: some View { + VStack(alignment: .leading, spacing: 14) { + Text( + String( + localized: "sidebar.help.feedback.note", + defaultValue: "A human will read this! You can also reach us at founders@manaflow.com." + ) + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) + .font(.system(size: 12, weight: .medium)) + TextField( + String(localized: "sidebar.help.feedback.emailPlaceholder", defaultValue: "you@example.com"), + text: $email + ) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) + .accessibilityIdentifier("SidebarFeedbackEmailField") + } + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(String(localized: "sidebar.help.feedback.message", defaultValue: "Message")) + .font(.system(size: 12, weight: .medium)) + Spacer(minLength: 0) + Text("\(message.count)/\(FeedbackComposerSettings.maxMessageLength)") + .font(.system(size: 11)) + .foregroundStyle( + message.count > FeedbackComposerSettings.maxMessageLength + ? Color.red + : Color.secondary + ) + } + + FeedbackComposerMessageEditor( + text: $message, + placeholder: String( + localized: "sidebar.help.feedback.messagePlaceholder", + defaultValue: "Share feedback, feature requests, or issues." + ), + accessibilityLabel: String(localized: "sidebar.help.feedback.message", defaultValue: "Message"), + accessibilityIdentifier: "SidebarFeedbackMessageEditor" + ) + .frame(minHeight: 180) + } + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button { + chooseAttachments() + } label: { + Label( + String(localized: "sidebar.help.feedback.attachImages", defaultValue: "Attach Images"), + systemImage: "paperclip" + ) + } + .accessibilityIdentifier("SidebarFeedbackAttachButton") + + Text( + String( + localized: "sidebar.help.feedback.attachmentsHint", + defaultValue: "Up to 10 images." + ) + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + if attachments.isEmpty == false { + VStack(alignment: .leading, spacing: 6) { + ForEach(attachments) { attachment in + HStack(spacing: 8) { + Image(systemName: "photo") + .foregroundStyle(.secondary) + Text(attachment.fileName) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(attachment.displaySize) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Button( + String(localized: "sidebar.help.feedback.removeAttachment", defaultValue: "Remove") + ) { + removeAttachment(attachment) + } + .buttonStyle(.link) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + } + } + + if let submissionErrorMessage, submissionErrorMessage.isEmpty == false { + Text(submissionErrorMessage) + .font(.system(size: 12)) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button(String(localized: "sidebar.help.feedback.cancel", defaultValue: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button { + Task { await submitFeedback() } + } label: { + if isSubmitting { + ProgressView() + .controlSize(.small) + } else { + Text(String(localized: "sidebar.help.feedback.send", defaultValue: "Send")) + } + } + .keyboardShortcut(.defaultAction) + .disabled(!canSubmit) + .accessibilityIdentifier("SidebarFeedbackSendButton") + } + } + } + + private func chooseAttachments() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.allowedContentTypes = [.image] + panel.title = String( + localized: "sidebar.help.feedback.attachImages.title", + defaultValue: "Attach Images" + ) + panel.prompt = String( + localized: "sidebar.help.feedback.attachImages.prompt", + defaultValue: "Attach" + ) + + guard panel.runModal() == .OK else { return } + + var updatedAttachments = attachments + var knownPaths = Set(updatedAttachments.map(\.standardizedPath)) + var firstIssue: String? + + for url in panel.urls { + let normalizedPath = url.standardizedFileURL.path + if knownPaths.contains(normalizedPath) { + continue + } + if updatedAttachments.count >= FeedbackComposerSettings.maxAttachmentCount { + firstIssue = String( + localized: "sidebar.help.feedback.tooManyImages", + defaultValue: "You can attach up to 10 images." + ) + break + } + + guard let attachment = try? FeedbackComposerAttachment(url: url) else { + firstIssue = String( + localized: "sidebar.help.feedback.invalidImageSelection", + defaultValue: "One of the selected files could not be attached." + ) + continue + } + updatedAttachments.append(attachment) + knownPaths.insert(normalizedPath) + } + + attachments = updatedAttachments + submissionErrorMessage = firstIssue + } + + private func removeAttachment(_ attachment: FeedbackComposerAttachment) { + attachments.removeAll { $0.id == attachment.id } + submissionErrorMessage = nil + } + + private func submitFeedback() async { + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMessage = trimmedMessage + + guard isValidEmail(trimmedEmail) else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.invalidEmail", + defaultValue: "Enter a valid email address." + ) + return + } + + guard normalizedMessage.isEmpty == false else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.emptyMessage", + defaultValue: "Enter a message before sending." + ) + return + } + + guard message.count <= FeedbackComposerSettings.maxMessageLength else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.messageTooLong", + defaultValue: "Your message is too long." + ) + return + } + + await MainActor.run { + email = trimmedEmail + submissionErrorMessage = nil + isSubmitting = true + } + + do { + try await FeedbackComposerClient.submit( + email: trimmedEmail, + message: normalizedMessage, + attachments: attachments + ) + await MainActor.run { + isSubmitting = false + didSend = true + attachments = [] + } + } catch { + await MainActor.run { + isSubmitting = false + submissionErrorMessage = userFacingErrorMessage(for: error) + } + } + } + + private func isValidEmail(_ rawValue: String) -> Bool { + let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard email.isEmpty == false else { return false } + let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# + return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) + } + + private func userFacingErrorMessage(for error: Error) -> String { + guard let submissionError = error as? FeedbackComposerSubmissionError else { + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + } + + switch submissionError { + case .invalidEndpoint: + return String( + localized: "sidebar.help.feedback.endpointError", + defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead." + ) + case .invalidResponse: + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + case .attachmentReadFailed: + return String( + localized: "sidebar.help.feedback.invalidImageSelection", + defaultValue: "One of the selected files could not be attached." + ) + case .attachmentPreparationFailed: + return String( + localized: "sidebar.help.feedback.totalImagesTooLarge", + defaultValue: "These images are too large to send together. Remove a few and try again." + ) + case .transport(let transportError): + if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { + return String( + localized: "sidebar.help.feedback.connectionError", + defaultValue: "Couldn't send feedback. Check your connection and try again." + ) + } + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + case .rejected(let statusCode): + switch statusCode { + case 400, 413, 415: + return String( + localized: "sidebar.help.feedback.validationError", + defaultValue: "Check your message and attachments, then try again." + ) + case 429: + return String( + localized: "sidebar.help.feedback.rateLimited", + defaultValue: "Too many feedback attempts. Please try again later." + ) + case 500...599: + return String( + localized: "sidebar.help.feedback.endpointError", + defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead." + ) + default: + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + } + } + } +} + +private struct SidebarHelpMenuButton: View { + private let docsURL = URL(string: "https://cmux.dev/docs") + private let changelogURL = URL(string: "https://cmux.dev/docs/changelog") + private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux") + private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues") + private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") + private let buttonSize: CGFloat = 22 + private let iconSize: CGFloat = 11 + @AppStorage(KeyboardShortcutSettings.Action.sendFeedback.defaultsKey) private var sendFeedbackShortcutData = Data() + + let onSendFeedback: () -> Void + + @State private var isPopoverPresented = false + + private var sendFeedbackShortcutHint: String { + decodeShortcut( + from: sendFeedbackShortcutData, + fallback: KeyboardShortcutSettings.Action.sendFeedback.defaultShortcut + ).displayString + } + + var body: some View { + Button { + isPopoverPresented.toggle() + } label: { + Image(systemName: "questionmark.circle") + .symbolRenderingMode(.monochrome) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + helpPopover + } + .accessibilityElement(children: .ignore) + .help(helpTitle) + .accessibilityLabel(helpTitle) + .accessibilityIdentifier("SidebarHelpMenuButton") + } + + private var helpPopover: some View { + VStack(alignment: .leading, spacing: 2) { + helpOptionButton( + title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), + action: .sendFeedback, + accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", + isExternalLink: false, + shortcutHint: sendFeedbackShortcutHint, + trailingSystemImage: "bubble.left.and.text.bubble.right" + ) + helpOptionButton( + title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), + action: .keyboardShortcuts, + accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", + isExternalLink: false + ) + if docsURL != nil { + helpOptionButton( + title: String(localized: "about.docs", defaultValue: "Docs"), + action: .docs, + accessibilityIdentifier: "SidebarHelpMenuOptionDocs", + isExternalLink: true + ) + } + if changelogURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), + action: .changelog, + accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", + isExternalLink: true + ) + } + if githubURL != nil { + helpOptionButton( + title: String(localized: "about.github", defaultValue: "GitHub"), + action: .github, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", + isExternalLink: true + ) + } + if githubIssuesURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), + action: .githubIssues, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", + isExternalLink: true + ) + } + helpOptionButton( + title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), + action: .checkForUpdates, + accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", + isExternalLink: false + ) + } + .padding(8) + .frame(minWidth: 200) + } + + private func helpOptionButton( + title: String, + action: SidebarHelpMenuAction, + accessibilityIdentifier: String, + isExternalLink: Bool, + shortcutHint: String? = nil, + trailingSystemImage: String? = nil + ) -> some View { + Button { + isPopoverPresented = false + perform(action) + } label: { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 12)) + Spacer(minLength: 0) + if let shortcutHint { + helpOptionShortcutHint(text: shortcutHint) + } + if let trailingSystemImage { + helpOptionTrailingIcon(systemName: trailingSystemImage) + } + if isExternalLink { + helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) + } + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + } + + private func helpOptionShortcutHint(text: String) -> some View { + Text(text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { + Image(systemName: systemName) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func perform(_ action: SidebarHelpMenuAction) { + switch action { + case .keyboardShortcuts: + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow( + debugSource: "sidebarHelpMenu.keyboardShortcuts", + navigationTarget: .keyboardShortcuts + ) + } else { + AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + } + } + case .docs: + guard let docsURL else { return } + NSWorkspace.shared.open(docsURL) + case .changelog: + guard let changelogURL else { return } + NSWorkspace.shared.open(changelogURL) + case .github: + guard let githubURL else { return } + NSWorkspace.shared.open(githubURL) + case .githubIssues: + guard let githubIssuesURL else { return } + NSWorkspace.shared.open(githubIssuesURL) + case .checkForUpdates: + Task { @MainActor in + AppDelegate.shared?.checkForUpdates(nil) + } + case .sendFeedback: + isPopoverPresented = false + onSendFeedback() + } + } + + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { + guard !data.isEmpty, + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return fallback + } + return shortcut + } +} + +private struct SidebarFooterIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + SidebarFooterIconButtonStyleBody(configuration: configuration) + } +} + +private struct SidebarFooterIconButtonStyleBody: View { + let configuration: SidebarFooterIconButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + #if DEBUG private struct SidebarDevFooter: View { @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner var body: some View { VStack(alignment: .leading, spacing: 6) { - UpdatePill(model: updateViewModel) - Text("THIS IS A DEV BUILD") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.red) + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + if showSidebarDevBuildBanner { + Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.red) + } } - .padding(.horizontal, 10) - .padding(.bottom, 10) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) } } #endif diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index dae65482..f06c255b 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -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: diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 89858475..fc803000 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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() 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 { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 07295066..198d42c0 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -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? } - 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 } diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index b3b7a971..b96325db 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -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 + } + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 528669a6..2c2efbfc 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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 = [] /// 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) { + 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") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8f8eadf1..f2b72af4 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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.size) + ) + } +#endif + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.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...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.. - Send text to a workspace's selected terminal (test-only) is_terminal_focused - 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 " } + + 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) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 1f1dac18..5bb768cb 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -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 diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a45cf6ed..58d88702 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 00cd360b..99ea9f8f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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() { diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 22b09b39..63ff111f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -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( diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9e34690f..adb78505 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -412,7 +412,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } @MainActor - func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusForPortalHostedWebView() { + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() { _ = NSApplication.shared AppDelegate.installWindowResponderSwizzlesForTesting() @@ -422,40 +422,51 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { backing: .buffered, defer: false ) - let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) - window.contentView = container - - let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 240, height: 150)) - container.addSubview(anchor) - - let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) - webView.addSubview(descendant) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView window.makeKeyAndOrderFront(nil) - container.layoutSubtreeIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1) - BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) - defer { - BrowserWindowPortalRegistry.detach(webView: webView) AppDelegate.clearWindowFirstResponderGuardTesting() window.orderOut(nil) } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: host.bounds) + slot.autoresizingMask = [.width, .height] + host.addSubview(slot) + + let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + slot.addSubview(webView) + + let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height)) + inspector.autoresizingMask = [.minXMargin, .height] + slot.addSubview(inspector) + webView.allowsFirstResponderAcquisition = false _ = window.makeFirstResponder(nil) - XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context") + XCTAssertFalse( + window.makeFirstResponder(inspector), + "Expected portal-hosted inspector focus to stay blocked without pointer click context" + ) - let timestamp = ProcessInfo.processInfo.systemUptime - let pointerPointInContent = NSPoint(x: anchor.frame.midX, y: anchor.frame.midY) - let pointerPointInWindow = container.convert(pointerPointInContent, to: nil) + let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) + let pointInWindow = inspector.convert(pointInInspector, to: nil) let pointerDownEvent = NSEvent.mouseEvent( with: .leftMouseDown, - location: pointerPointInWindow, + location: pointInWindow, modifierFlags: [], - timestamp: timestamp, + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: 1, @@ -467,8 +478,83 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) _ = window.makeFirstResponder(nil) XCTAssertTrue( - window.makeFirstResponder(descendant), - "Expected portal-hosted pointer click context to bypass blocked policy" + window.makeFirstResponder(inspector), + "Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target" + ) + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + + let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + + defer { + BrowserWindowPortalRegistry.detach(webView: webView) + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected bound portal slot") + return + } + + let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height)) + inspector.autoresizingMask = [.minXMargin, .height] + slot.addSubview(inspector) + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse( + window.makeFirstResponder(inspector), + "Expected bound portal inspector focus to stay blocked without pointer click context" + ) + + let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) + let pointInWindow = inspector.convert(pointInInspector, to: nil) + XCTAssertTrue( + BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView, + "Expected portal registry to resolve the owning web view from a click inside inspector chrome" + ) + + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: pointInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil) + _ = window.makeFirstResponder(nil) + XCTAssertTrue( + window.makeFirstResponder(inspector), + "Expected bound portal inspector click to bypass blocked policy through portal registry fallback" ) } @@ -2300,6 +2386,8 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { @MainActor final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { + private final class WKInspectorProbeView: NSView {} + private final class FakeInspector: NSObject { private(set) var showCount = 0 private(set) var closeCount = 0 @@ -2446,12 +2534,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, - browserSearchState: nil, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, - paneDropZone: nil + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -2484,12 +2573,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, - browserSearchState: nil, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, - paneDropZone: nil + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -2498,6 +2588,42 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertNotNil(panel.webView.superview) window.orderOut(nil) } + + func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) + panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height) + host.addSubview(panel.webView) + + let inspectorContainer = NSView( + frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + host.addSubview(inspectorContainer) + + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) + panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80) + host.addSubview(panel.webView) + + let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80)) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + host.addSubview(inspectorContainer) + + XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } } final class WorkspaceShortcutMapperTests: XCTestCase { @@ -2995,71 +3121,6 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { } } -final class TerminalCommandShortcutRoutingPolicyTests: XCTestCase { - func testRoutesCommandCToTerminalWhenNoSelection() { - XCTAssertTrue( - shouldRouteTerminalCommandShortcutToGhostty( - flags: [.command], - chars: "c", - keyCode: 8, // kVK_ANSI_C - terminalHasSelection: false - ) - ) - } - - func testKeepsCommandCCopyMenuRoutedWhenSelectionExists() { - XCTAssertFalse( - shouldRouteTerminalCommandShortcutToGhostty( - flags: [.command], - chars: "c", - keyCode: 8, // kVK_ANSI_C - terminalHasSelection: true - ) - ) - } - - func testKeepsCommandCommaMenuRoutedForPreferences() { - XCTAssertFalse( - shouldRouteTerminalCommandShortcutToGhostty( - flags: [.command], - chars: ",", - keyCode: 43, // kVK_ANSI_Comma - terminalHasSelection: false - ) - ) - } - - func testRequiresCommandModifier() { - XCTAssertFalse( - shouldRouteTerminalCommandShortcutToGhostty( - flags: [.control], - chars: "c", - keyCode: 8, - terminalHasSelection: false - ) - ) - } - - func testRoutesOtherCommandShortcutsToTerminal() { - XCTAssertTrue( - shouldRouteTerminalCommandShortcutToGhostty( - flags: [.command, .option], - chars: "c", - keyCode: 8, - terminalHasSelection: false - ) - ) - XCTAssertTrue( - shouldRouteTerminalCommandShortcutToGhostty( - flags: [.command], - chars: "v", - keyCode: 9, // kVK_ANSI_V - terminalHasSelection: false - ) - ) - } -} - final class GhosttyResponderResolutionTests: XCTestCase { private final class FocusProbeView: NSView { override var acceptsFirstResponder: Bool { true } @@ -3590,6 +3651,35 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { } } +final class DevBuildBannerDebugSettingsTests: XCTestCase { + func testShowSidebarBannerDefaultsToVisible() { + let suiteName = "DevBuildBannerDebugSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + } + + func testShowSidebarBannerRespectsStoredValue() { + let suiteName = "DevBuildBannerDebugSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + + defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + } +} + final class ShortcutHintLanePlannerTests: XCTestCase { func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() { let intervals: [ClosedRange] = [0...20, 28...40, 48...64] @@ -6756,8 +6846,6 @@ final class NotificationDockBadgeTests: XCTestCase { } override func tearDown() { - AppFocusState.overrideIsFocused = nil - AppDelegate.shared = nil TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting() TerminalNotificationStore.shared.replaceNotificationsForTesting([]) super.tearDown() @@ -7261,155 +7349,6 @@ final class NotificationDockBadgeTests: XCTestCase { XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id) } - func testFocusedTabNotificationIsStoredWhenNativeDeliveryIsSuppressed() { - let store = TerminalNotificationStore.shared - store.replaceNotificationsForTesting([]) - - let appDelegate = AppDelegate() - let tabManager = TabManager() - appDelegate.tabManager = tabManager - AppDelegate.shared = appDelegate - AppFocusState.overrideIsFocused = true - - guard let tabId = tabManager.selectedTabId else { - XCTFail("Expected selected tab for notification test") - return - } - - store.addNotification( - tabId: tabId, - surfaceId: nil, - title: "Needs input", - subtitle: "", - body: "agent requires user action" - ) - - XCTAssertEqual(store.unreadCount(forTabId: tabId), 1) - guard let latest = store.latestNotification(forTabId: tabId) else { - XCTFail("Expected notification to be stored for focused tab") - return - } - XCTAssertEqual(latest.tabId, tabId) - XCTAssertEqual(latest.title, "Needs input") - XCTAssertEqual(latest.body, "agent requires user action") - XCTAssertFalse(latest.isRead) - } - - func testApplicationDidBecomeActiveDoesNotMarkFocusedNotificationRead() { - let store = TerminalNotificationStore.shared - let appDelegate = AppDelegate() - let tabManager = TabManager() - appDelegate.tabManager = tabManager - appDelegate.notificationStore = store - AppDelegate.shared = appDelegate - AppFocusState.overrideIsFocused = true - - guard let tabId = tabManager.selectedTabId, - let surfaceId = tabManager.focusedSurfaceId(for: tabId) else { - XCTFail("Expected selected tab and focused surface for activation test") - return - } - - let notification = TerminalNotification( - id: UUID(), - tabId: tabId, - surfaceId: surfaceId, - title: "Unread", - subtitle: "", - body: "should persist across app activation", - createdAt: Date(), - isRead: false - ) - store.replaceNotificationsForTesting([notification]) - - appDelegate.applicationDidBecomeActive( - Notification(name: NSApplication.didBecomeActiveNotification) - ) - - XCTAssertTrue(store.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId)) - XCTAssertFalse(store.notifications[0].isRead) - } - - func testSelectingWorkspaceDoesNotMarkFocusedNotificationRead() { - let store = TerminalNotificationStore.shared - let appDelegate = AppDelegate() - let tabManager = TabManager() - appDelegate.tabManager = tabManager - appDelegate.notificationStore = store - AppDelegate.shared = appDelegate - AppFocusState.overrideIsFocused = true - - guard let originalTabId = tabManager.selectedTabId, - let originalSurfaceId = tabManager.focusedSurfaceId(for: originalTabId) else { - XCTFail("Expected selected tab and focused surface for workspace selection test") - return - } - guard let originalWorkspace = tabManager.tabs.first(where: { $0.id == originalTabId }) else { - XCTFail("Expected original workspace for workspace selection test") - return - } - - let notification = TerminalNotification( - id: UUID(), - tabId: originalTabId, - surfaceId: originalSurfaceId, - title: "Unread", - subtitle: "", - body: "should persist across workspace selection", - createdAt: Date(), - isRead: false - ) - store.replaceNotificationsForTesting([notification]) - - _ = tabManager.addWorkspace(select: true) - tabManager.selectWorkspace(originalWorkspace) - - let drained = expectation(description: "workspace selection side effects drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1.0) - - XCTAssertEqual(tabManager.selectedTabId, originalTabId) - XCTAssertTrue(store.hasUnreadNotification(forTabId: originalTabId, surfaceId: originalSurfaceId)) - XCTAssertFalse(store.notifications[0].isRead) - } - - func testNotificationFocusNavigationDoesNotMarkNotificationRead() { - let store = TerminalNotificationStore.shared - let appDelegate = AppDelegate() - let tabManager = TabManager() - appDelegate.tabManager = tabManager - appDelegate.notificationStore = store - AppDelegate.shared = appDelegate - AppFocusState.overrideIsFocused = true - - guard let tabId = tabManager.selectedTabId, - let surfaceId = tabManager.focusedSurfaceId(for: tabId) else { - XCTFail("Expected selected tab and focused surface for notification focus test") - return - } - - let notification = TerminalNotification( - id: UUID(), - tabId: tabId, - surfaceId: surfaceId, - title: "Unread", - subtitle: "", - body: "should persist after notification focus", - createdAt: Date(), - isRead: false - ) - store.replaceNotificationsForTesting([notification]) - - tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) - - let drained = expectation(description: "notification focus drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 1.0) - - XCTAssertTrue(store.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId)) - XCTAssertFalse(store.notifications[0].isRead) - } - func testNotificationIndexesUpdateAfterReadAndClearMutations() { let tab = UUID() let surfaceUnread = UUID() @@ -7907,8 +7846,56 @@ final class WindowBrowserHostViewTests: XCTestCase { } } + private final class PrimaryPageProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class WKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class EdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x <= 12 ? nil : self + } + } + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool { + guard let hit else { return false } + if hit === pageView || hit.isDescendant(of: pageView) { + return false + } + if hit === inspectorView || hit.isDescendant(of: inspectorView) { + return true + } + return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit)) + } + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), @@ -7937,12 +7924,18 @@ final class WindowBrowserHostViewTests: XCTestCase { splitView.adjustSubviews() contentView.layoutSubtreeIfNeeded() - let host = WindowBrowserHostView(frame: contentView.bounds) + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] let child = CapturingView(frame: host.bounds) child.autoresizingMask = [.width, .height] host.addSubview(child) - contentView.addSubview(host) + container.addSubview(host, positioned: .above, relativeTo: contentView) let dividerPointInSplit = NSPoint( x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), @@ -7994,6 +7987,705 @@ final class WindowBrowserHostViewTests: XCTestCase { ) ) } + + func testHostViewKeepsHostedInspectorDividerInteractive() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + // Underlying app layout split that should still be pass-through. + let appSplit = NSSplitView(frame: contentView.bounds) + appSplit.autoresizingMask = [.width, .height] + appSplit.isVertical = true + appSplit.dividerStyle = .thin + let appSplitDelegate = BonsplitMockSplitDelegate() + appSplit.delegate = appSplitDelegate + let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height)) + let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height)) + appSplit.addSubview(leading) + appSplit.addSubview(trailing) + contentView.addSubview(appSplit) + appSplit.adjustSubviews() + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + // WebKit inspector uses an internal split (page + console). Divider drags + // here must stay in hosted content, not pass through to appSplit behind it. + let inspectorSplit = NSSplitView(frame: host.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = false + inspectorSplit.dividerStyle = .thin + let inspectorDelegate = BonsplitMockSplitDelegate() + inspectorSplit.delegate = inspectorDelegate + let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160)) + let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99)) + inspectorSplit.addSubview(pageView) + inspectorSplit.addSubview(consoleView) + host.addSubview(inspectorSplit) + inspectorSplit.setPosition(160, ofDividerAt: 0) + inspectorSplit.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let appDividerPointInSplit = NSPoint( + x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5), + y: appSplit.bounds.midY + ) + let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil) + let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil) + XCTAssertNil( + host.hitTest(appDividerPointInHost), + "Underlying app split divider should still pass through with a hosted inspector split present" + ) + + let dividerPointInInspector = NSPoint( + x: inspectorSplit.bounds.midX, + y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5) + ) + let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let hit = host.hitTest(dividerPointInHost) + + XCTAssertNotNil( + hit, + "Inspector divider should receive hit-testing in hosted content, not pass through" + ) + XCTAssertFalse(hit === host) + if let hit { + XCTAssertTrue( + hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), + "Expected hit to remain inside inspector split subtree" + ) + } + } + + func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let inspectorSplit = NSSplitView(frame: slot.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = true + inspectorSplit.dividerStyle = .thin + let inspectorDelegate = BonsplitMockSplitDelegate() + inspectorSplit.delegate = inspectorDelegate + let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height)) + let inspectorView = CapturingView( + frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height) + ) + inspectorSplit.addSubview(pageView) + inspectorSplit.addSubview(inspectorView) + slot.addSubview(inspectorSplit) + inspectorSplit.setPosition(1, ofDividerAt: 0) + inspectorSplit.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSplit = NSPoint( + x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5), + y: inspectorSplit.bounds.midY + ) + let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5) + XCTAssertTrue( + abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide, + "Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone" + ) + + let hit = host.hitTest(dividerPointInHost) + XCTAssertNotNil( + hit, + "Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge" + ) + XCTAssertFalse(hit === host) + if let hit { + XCTAssertTrue( + hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), + "Expected hit to remain inside hosted inspector split subtree at the slot edge" + ) + } + } + + func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) + ) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY) + let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) + let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))" + ) + let interiorHit = host.hitTest(bodyPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), + "Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" + ) + } + + func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let wrapper = NSView(frame: slot.bounds) + wrapper.autoresizingMask = [.width, .height] + slot.addSubview(wrapper) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)) + let inspectorContainer = NSView( + frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + wrapper.addSubview(pageView) + wrapper.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY) + let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) + let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))" + ) + let interiorHit = host.hitTest(bodyPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), + "Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" + ) + } + + func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let wrapper = NSView(frame: slot.bounds) + wrapper.autoresizingMask = [.width, .height] + slot.addSubview(wrapper) + + let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height) + let originalInspectorFrame = NSRect( + x: 92, + y: 0, + width: wrapper.bounds.width - 92, + height: wrapper.bounds.height + ) + let pageView = PrimaryPageProbeView(frame: originalPageFrame) + let inspectorContainer = NSView(frame: originalInspectorFrame) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + wrapper.addSubview(pageView) + wrapper.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + let draggedPageWidth = pageView.frame.width + let draggedInspectorMinX = inspectorContainer.frame.minX + XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) + XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) + + pageView.frame = originalPageFrame + inspectorContainer.frame = originalInspectorFrame + slot.needsLayout = true + slot.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) + XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) + } + + func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) + let inspectorView = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) + ) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + dividerHit === host, + "Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 92) + XCTAssertGreaterThan(inspectorView.frame.minX, 92) + } + + func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) + slot.autoresizingMask = [.minXMargin, .height] + host.addSubview(slot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height)) + let inspectorView = WKInspectorProbeView(frame: slot.bounds) + slot.addSubview(pageView) + slot.addSubview(inspectorView) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) + let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + + XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide) + let dividerHit = host.hitTest(dividerPointInHost) + XCTAssertTrue( + isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), + "Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))" + ) + } +} + +@MainActor +final class BrowserPanelHostContainerViewTests: XCTestCase { + private final class PrimaryPageProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class WKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class EdgeTransparentWKInspectorProbeView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + guard bounds.contains(localPoint) else { return nil } + return localPoint.x <= 12 ? nil : self + } + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + func testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = NSView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY) + let interiorHit = host.hitTest(bodyPointInHost) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Browser panel host should claim the right-docked divider edge for the manual resize path" + ) + XCTAssertTrue( + interiorHit == nil || interiorHit !== host, + "Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))" + ) + } + + func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height)) + let inspectorContainer = NSView(frame: webViewRoot.bounds) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 0) + XCTAssertGreaterThan(inspectorContainer.frame.minX, 0) + } + + func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + XCTAssertTrue( + host.hitTest(dividerPointInHost) === host, + "Browser panel host should only take the manual fallback path when the divider edge is not natively hittable" + ) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan(pageView.frame.width, 92) + XCTAssertGreaterThan(inspectorContainer.frame.minX, 92) + } + + func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView( + frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height) + ) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) + let originalInspectorFrame = NSRect( + x: 92, + y: 0, + width: webViewRoot.bounds.width - 92, + height: webViewRoot.bounds.height + ) + let pageView = PrimaryPageProbeView(frame: originalPageFrame) + let inspectorContainer = NSView(frame: originalInspectorFrame) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) + host.mouseDown(with: down) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + let draggedPageWidth = pageView.frame.width + let draggedInspectorMinX = inspectorContainer.frame.minX + XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) + XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) + + pageView.frame = originalPageFrame + inspectorContainer.frame = originalInspectorFrame + host.needsLayout = true + host.layoutSubtreeIfNeeded() + + XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) + XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) + } } @MainActor @@ -8027,6 +8719,27 @@ final class BrowserPaneDropRoutingTests: XCTestCase { ) } + func testTopChromeHeightPushesTopSplitThresholdIntoWebView() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 110), + in: size, + topChromeHeight: 36 + ), + .center + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 150), + in: size, + topChromeHeight: 36 + ), + .top + ) + } + func testHitTestingCapturesOnlyForRelevantDragEvents() { XCTAssertTrue( BrowserPaneDropTargetView.shouldCaptureHitTesting( @@ -8124,22 +8837,24 @@ final class WindowBrowserSlotViewTests: XCTestCase { } func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() { - let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let slot = WindowBrowserSlotView(frame: container.bounds) + container.addSubview(slot) let child = CapturingView(frame: slot.bounds) child.autoresizingMask = [.width, .height] slot.addSubview(child) slot.setDropZoneOverlay(zone: .right) - slot.layoutSubtreeIfNeeded() + container.layoutSubtreeIfNeeded() - guard let overlay = slot.subviews.first(where: { - $0 !== child && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + guard let overlay = container.subviews.first(where: { + $0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") }) else { XCTFail("Expected browser slot drop-zone overlay") return } - XCTAssertTrue(slot.subviews.last === overlay, "Overlay should stay above the hosted web view") + XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view") XCTAssertFalse(overlay.isHidden) XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5) XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) @@ -8152,6 +8867,35 @@ final class WindowBrowserSlotViewTests: XCTestCase { advanceAnimations() XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay") } + + func testTopDropZoneOverlayUsesFullBrowserContentHeight() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let slot = WindowBrowserSlotView(frame: container.bounds) + container.addSubview(slot) + + slot.setPaneTopChromeHeight(20) + slot.setDropZoneOverlay(zone: .top) + container.layoutSubtreeIfNeeded() + + guard let overlay = container.subviews.first(where: { + String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) else { + XCTFail("Expected browser slot drop-zone overlay") + return + } + + XCTAssertFalse(overlay.isHidden) + XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5) + XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY) + XCTAssertEqual(slot.layer?.masksToBounds, true) + + slot.setDropZoneOverlay(zone: nil) + advanceAnimations() + XCTAssertEqual(slot.layer?.masksToBounds, true) + } } @MainActor @@ -9571,8 +10315,11 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { } private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? { - slot.subviews.first(where: { - $0 !== webView && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + let candidates = slot.subviews + (slot.superview?.subviews ?? []) + return candidates.first(where: { + $0 !== slot && + $0 !== webView && + String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") }) } @@ -9883,9 +10630,9 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right) slot.layoutSubtreeIfNeeded() XCTAssertFalse(overlay.isHidden) - XCTAssertTrue(slot.subviews.last === overlay, "Overlay should remain above the hosted web view") - XCTAssertEqual(overlay.frame.origin.x, 110, accuracy: 0.5) - XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) + XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view") + XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5) @@ -10022,6 +10769,41 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { BrowserWindowPortalRegistry.detach(webView: webView) XCTAssertNil(webView.superview) } + + func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + contentView.addSubview(anchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + XCTAssertFalse(slot.isHidden) + + BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest") + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment") + XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot") + } } @MainActor @@ -10075,6 +10857,110 @@ final class FileDropOverlayViewTests: XCTestCase { } } +@MainActor +final class MarkdownPanelPointerObserverViewTests: XCTestCase { + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + return window + } + + private func makeMouseEvent( + type: NSEvent.EventType, + location: NSPoint, + window: NSWindow, + eventNumber: Int = 1 + ) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: eventNumber, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Expected to create mouse event") + } + return event + } + + func testObserverTriggersFocusForVisibleLeftClickInsideBounds() { + let window = makeWindow() + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) + overlay.autoresizingMask = [.width, .height] + let focusExpectation = expectation(description: "observer forwards focus callback") + var pointerDownCount = 0 + overlay.onPointerDown = { + pointerDownCount += 1 + focusExpectation.fulfill() + } + contentView.addSubview(overlay) + + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window) + ) + wait(for: [focusExpectation], timeout: 1.0) + + XCTAssertEqual(pointerDownCount, 1) + } + + func testObserverIgnoresOutsideOrForeignWindowClicks() { + let window = makeWindow() + defer { window.orderOut(nil) } + let otherWindow = makeWindow() + defer { otherWindow.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) + overlay.autoresizingMask = [.width, .height] + let noFocusExpectation = expectation(description: "observer ignores invalid clicks") + noFocusExpectation.isInverted = true + var pointerDownCount = 0 + overlay.onPointerDown = { + pointerDownCount += 1 + noFocusExpectation.fulfill() + } + contentView.addSubview(overlay) + + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window) + ) + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2) + ) + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3) + ) + wait(for: [noFocusExpectation], timeout: 0.1) + + XCTAssertEqual(pointerDownCount, 0) + } + + func testObserverDoesNotParticipateInHitTesting() { + let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30))) + } +} + final class BrowserLinkOpenSettingsTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index 9cd9f038..f1c6b630 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -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 diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index f698b9af..632ad44d 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -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"] ?? "")" + ) + 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) ?? "" + 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 } ?? "" + 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() + 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() + 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.. 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) + ) + } +} diff --git a/scripts/reload.sh b/scripts/reload.sh index 3cd2bb63..43a58863 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -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 diff --git a/scripts/reloadp.sh b/scripts/reloadp.sh index fbb75fe8..62bc0597 100755 --- a/scripts/reloadp.sh +++ b/scripts/reloadp.sh @@ -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" diff --git a/scripts/reloads.sh b/scripts/reloads.sh index f2c2dfad..f06bc246 100755 --- a/scripts/reloads.sh +++ b/scripts/reloads.sh @@ -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 diff --git a/skills/cmux-browser/SKILL.md b/skills/cmux-browser/SKILL.md index 8d398377..aed36c61 100644 --- a/skills/cmux-browser/SKILL.md +++ b/skills/cmux-browser/SKILL.md @@ -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 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. diff --git a/skills/cmux-browser/references/commands.md b/skills/cmux-browser/references/commands.md index 5cc37625..72693a5d 100644 --- a/skills/cmux-browser/references/commands.md +++ b/skills/cmux-browser/references/commands.md @@ -11,7 +11,7 @@ This maps common `agent-browser` usage to `cmux browser` usage. - `agent-browser fill ` -> `cmux browser fill ` - `agent-browser type ` -> `cmux browser type ` - `agent-browser select ` -> `cmux browser select ` -- `agent-browser get text ` -> `cmux browser get text ` +- `agent-browser get text ` -> `cmux browser get text ` - `agent-browser get url` -> `cmux browser get url` - `agent-browser get title` -> `cmux browser get title` @@ -34,7 +34,13 @@ cmux browser get url|title ```bash cmux browser snapshot --interactive cmux browser snapshot --interactive --compact --max-depth 3 -cmux browser get text|html|value|attr|count|box|styles ... +cmux browser get text body +cmux browser get html body +cmux browser get value "#email" +cmux browser get attr "#email" --attr placeholder +cmux browser get count ".row" +cmux browser get box "#submit" +cmux browser get styles "#submit" --property color cmux browser eval '' ``` diff --git a/skills/cmux-browser/templates/authenticated-session.sh b/skills/cmux-browser/templates/authenticated-session.sh index bf19a274..284b77af 100755 --- a/skills/cmux-browser/templates/authenticated-session.sh +++ b/skills/cmux-browser/templates/authenticated-session.sh @@ -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 diff --git a/skills/cmux-browser/templates/form-automation.sh b/skills/cmux-browser/templates/form-automation.sh index f8a9406c..0c50d15e 100755 --- a/skills/cmux-browser/templates/form-automation.sh +++ b/skills/cmux-browser/templates/form-automation.sh @@ -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 diff --git a/tests/test_focus_notification_dismiss.py b/tests/test_focus_notification_dismiss.py index 14cef434..d7569b65 100755 --- a/tests/test_focus_notification_dismiss.py +++ b/tests/test_focus_notification_dismiss.py @@ -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}") diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 23b4bf10..1ac25c4b 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -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)) diff --git a/tests_v2/test_browser_cli_agent_port.py b/tests_v2/test_browser_cli_agent_port.py index bbebdeec..d3cbdf99 100644 --- a/tests_v2/test_browser_cli_agent_port.py +++ b/tests_v2/test_browser_cli_agent_port.py @@ -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}") diff --git a/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py b/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py new file mode 100644 index 00000000..9e83ee0f --- /dev/null +++ b/tests_v2/test_cli_new_workspace_external_git_branch_refresh.py @@ -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-.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()) diff --git a/tests_v2/test_focus_notification_dismiss.py b/tests_v2/test_focus_notification_dismiss.py index 14cef434..d7569b65 100755 --- a/tests_v2/test_focus_notification_dismiss.py +++ b/tests_v2/test_focus_notification_dismiss.py @@ -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}") diff --git a/tests_v2/test_notifications.py b/tests_v2/test_notifications.py index 23b4bf10..1ac25c4b 100644 --- a/tests_v2/test_notifications.py +++ b/tests_v2/test_notifications.py @@ -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)) diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..389f57d6 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,3 @@ +RESEND_API_KEY= +CMUX_FEEDBACK_FROM_EMAIL= +CMUX_FEEDBACK_RATE_LIMIT_ID= diff --git a/web/app/api/feedback/route.ts b/web/app/api/feedback/route.ts new file mode 100644 index 00000000..33256634 --- /dev/null +++ b/web/app/api/feedback/route.ts @@ -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 + ? "

Attachments: none

" + : `

Attachments:

    ${input.attachments + .map( + (attachment) => + `
  • ${escapeHtml(attachment.filename)} (${escapeHtml( + attachment.contentType, + )}, ${attachment.size} bytes)
  • `, + ) + .join("")}
`; + + return ` +
+

cmux feedback

+

From: ${escapeHtml(input.email)}

+

App version: ${escapeHtml(input.appVersion || "unknown")}

+

App build: ${escapeHtml(input.appBuild || "unknown")}

+

App commit: ${escapeHtml(input.appCommit || "unknown")}

+

Bundle identifier: ${escapeHtml( + input.bundleIdentifier || "unknown", + )}

+

macOS: ${escapeHtml(input.osVersion || "unknown")}

+

Locale: ${escapeHtml(input.locale || "unknown")}

+ ${attachmentMarkup} +

Message

+
${escapeHtml(
+        input.message,
+      )}
+
+ `.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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function jsonError(message: string, status: number) { + return NextResponse.json( + { error: message }, + { + status, + headers: { + "Cache-Control": "no-store", + }, + }, + ); +} diff --git a/web/app/env.ts b/web/app/env.ts new file mode 100644 index 00000000..18ba7bcf --- /dev/null +++ b/web/app/env.ts @@ -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", +}); diff --git a/web/bun.lock b/web/bun.lock index abaa7c3f..d466e4f1 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/next.config.ts b/web/next.config.ts index 52213e80..06164a93 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,3 +1,4 @@ +import "./app/env"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { diff --git a/web/package-lock.json b/web/package-lock.json index a7b86b58..9f3d7da0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/package.json b/web/package.json index c69800e5..4540ac42 100644 --- a/web/package.json +++ b/web/package.json @@ -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",