diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 4bcab6b0..463e5a56 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -94,6 +94,9 @@ jobs: sleep $((attempt * 5)) done + - name: Run issue #952 regression guard + run: python3 tests/test_issue_952_socket_listener_recovery.py + - name: Run unit tests run: | set -euo pipefail diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bac7a85..c106a18a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,9 @@ jobs: sleep $((attempt * 5)) done + - name: Run issue #952 regression guard + run: python3 tests/test_issue_952_socket_listener_recovery.py + - name: Run unit tests run: | set -euo pipefail @@ -244,6 +247,9 @@ jobs: sleep $((attempt * 5)) done + - name: Run issue #952 regression guard + run: python3 tests/test_issue_952_socket_listener_recovery.py + - name: Create virtual display run: | set -euo pipefail diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..d300267f --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index ca636bf6..867479ad 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -122,6 +122,9 @@ jobs: sleep $((attempt * 5)) done + - name: Run issue #952 regression guard + run: python3 tests/test_issue_952_socket_listener_recovery.py + - name: Run unit tests if: ${{ !inputs.skip_unit_tests }} run: | diff --git a/CLAUDE.md b/CLAUDE.md index 18f06112..e1c1c942 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,10 +34,16 @@ When reporting a tagged reload result in chat, use the format for your agent typ Never use `/tmp/cmux-/...` app links in chat output. If the expected DerivedData path is missing, resolve the real `.app` path and report that `file://` URL. -After making code changes, always run the build: +After making code changes, always use `reload.sh --tag` to build and launch. **Never run bare `xcodebuild` or `open` an untagged `cmux DEV.app`.** Untagged builds share the default debug socket and bundle ID with other agents, causing conflicts and stealing focus. ```bash -xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build +./scripts/reload.sh --tag +``` + +If you only need to verify the build compiles (no launch), use a tagged derivedDataPath: + +```bash +xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux- build ``` When rebuilding GhosttyKit.xcframework, always use Release optimizations: @@ -107,6 +113,15 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Focus events: `focus.panel`, `focus.bonsplit`, `focus.firstResponder`, `focus.moveFocus` - Bonsplit events: `tab.select`, `tab.close`, `tab.dragStart`, `tab.drop`, `pane.focus`, `pane.drop`, `divider.dragStart` +## Regression test commit policy + +When adding a regression test for a bug fix, use a two-commit structure so CI proves the test catches the bug: + +1. **Commit 1:** Add the failing test only (no fix). CI should go red. +2. **Commit 2:** Add the fix. CI should go green. + +This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the test genuinely fails without the fix. + ## Pitfalls - **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`). @@ -115,6 +130,12 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. - **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc. +## Test quality policy + +- Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns. +- Tests must verify observable runtime behavior through executable paths (unit/integration/e2e/CLI), not implementation shape. +- If a behavior cannot be exercised end-to-end yet, add a small runtime seam or harness first, then test through that seam. + ## Socket command threading policy - Do not use `DispatchQueue.main.sync` for high-frequency socket telemetry commands (`report_*`, `ports_kick`, status/progress/log metadata updates). @@ -131,21 +152,14 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents). - All non-focus commands should preserve current user focus context while still applying data/model changes. -## E2E mac UI tests +## Testing policy -Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`: +**Never run tests locally.** All tests (E2E, UI, python socket tests) run via GitHub Actions or on the VM. -```bash -ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test' -``` - -## Basic tests - -Run basic automated tests on the UTM macOS VM (never on the host machine): - -```bash -ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmux DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit) && open "$APP" --env CMUX_SOCKET_MODE=allowAll && for i in {1..20}; do [ -S /tmp/cmux-debug.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py' -``` +- **E2E / UI tests:** trigger via `gh workflow run test-e2e.yml` (see cmuxterm-hq CLAUDE.md for details) +- **Unit tests:** `xcodebuild -scheme cmux-unit` is safe (no app launch), but prefer CI +- **Python socket tests (tests_v2/):** these connect to a running cmux instance's socket. Never launch an untagged `cmux DEV.app` to run them. If you must test locally, use a tagged build's socket (`/tmp/cmux-debug-.sock`) with `CMUX_SOCKET=/tmp/cmux-debug-.sock` +- **Never `open` an untagged `cmux DEV.app`** from DerivedData. It conflicts with the user's running debug instance. ## Ghostty submodule workflow diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4475f341..4172bb7e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1736,6 +1736,45 @@ struct CMUXCLI { return (cwd as NSString).appendingPathComponent(expanded) } + private func sanitizedFilenameComponent(_ raw: String) -> String { + let sanitized = raw.replacingOccurrences( + of: #"[^\p{L}\p{N}._-]+"#, + with: "-", + options: .regularExpression + ) + let trimmed = sanitized.trimmingCharacters(in: CharacterSet(charactersIn: "-.")) + return trimmed.isEmpty ? "item" : trimmed + } + + private func bestEffortPruneTemporaryFiles( + in directoryURL: URL, + keepingMostRecent maxCount: Int = 50, + maxAge: TimeInterval = 24 * 60 * 60 + ) { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + let now = Date() + let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]), + values.isRegularFile == true else { + return nil + } + return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast) + }.sorted { $0.date > $1.date } + + for (index, entry) in datedEntries.enumerated() { + if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge { + try? FileManager.default.removeItem(at: entry.url) + } + } + } + // MARK: - Markdown Commands private func runMarkdownCommand( @@ -2623,6 +2662,29 @@ struct CMUXCLI { } } + func displaySnapshotText(_ payload: [String: Any]) -> String { + let snapshotText = (payload["snapshot"] as? String) ?? "Empty page" + guard snapshotText.contains("\n- (empty)") else { + return snapshotText + } + + let url = ((payload["url"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let readyState = ((payload["ready_state"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + var lines = [snapshotText] + + if !url.isEmpty { + lines.append("url: \(url)") + } + if !readyState.isEmpty { + lines.append("ready_state: \(readyState)") + } + if url.isEmpty || url == "about:blank" { + lines.append("hint: run 'cmux browser get url' to verify navigation") + } + + return lines.joined(separator: "\n") + } + func displayBrowserValue(_ value: Any) -> String { if let dict = value as? [String: Any], let type = dict["__cmux_t"] as? String, @@ -2841,10 +2903,8 @@ struct CMUXCLI { let payload = try client.sendV2(method: "browser.snapshot", params: params) if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) - } else if let text = payload["snapshot"] as? String { - print(text) } else { - print("Empty page") + print(displaySnapshotText(payload)) } return } @@ -3052,17 +3112,139 @@ struct CMUXCLI { if subcommand == "screenshot" { let sid = try requireSurface() let (outPathOpt, _) = parseOption(subArgs, name: "--out") - let payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid]) - if let outPathOpt, - let b64 = payload["png_base64"] as? String, - let data = Data(base64Encoded: b64) { - try data.write(to: URL(fileURLWithPath: outPathOpt)) + let localJSONOutput = hasFlag(subArgs, name: "--json") + let outputAsJSON = jsonOutput || localJSONOutput + var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid]) + + func fileURL(fromPath rawPath: String) -> URL { + let resolvedPath = resolvePath(rawPath) + return URL(fileURLWithPath: resolvedPath).standardizedFileURL } - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + func writeScreenshot(_ data: Data, to destinationURL: URL) throws { + try FileManager.default.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: destinationURL, options: .atomic) + } + + func hasText(_ value: String?) -> Bool { + guard let value else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var screenshotPath = payload["path"] as? String + var screenshotURL = payload["url"] as? String + + func syncScreenshotLocationFields() { + if !hasText(screenshotPath), + let rawURL = screenshotURL, + let fileURL = URL(string: rawURL), + fileURL.isFileURL, + !fileURL.path.isEmpty { + screenshotPath = fileURL.path + } + if !hasText(screenshotURL), + let screenshotPath, + hasText(screenshotPath) { + screenshotURL = URL(fileURLWithPath: screenshotPath).standardizedFileURL.absoluteString + } + if let screenshotPath, hasText(screenshotPath) { + payload["path"] = screenshotPath + } + if let screenshotURL, hasText(screenshotURL) { + payload["url"] = screenshotURL + } + } + + func persistPayloadScreenshot(to destinationURL: URL, allowFailure: Bool) throws -> Bool { + if let sourcePath = screenshotPath, hasText(sourcePath) { + let sourceURL = URL(fileURLWithPath: sourcePath).standardizedFileURL + do { + if sourceURL.path != destinationURL.path { + try FileManager.default.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? FileManager.default.removeItem(at: destinationURL) + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } + return true + } catch { + if payload["png_base64"] == nil { + if allowFailure { + return false + } + throw error + } + } + } + + if let b64 = payload["png_base64"] as? String, + let data = Data(base64Encoded: b64) { + do { + try writeScreenshot(data, to: destinationURL) + return true + } catch { + if allowFailure { + return false + } + throw error + } + } + + return false + } + + if let outPathOpt { + let outputURL = fileURL(fromPath: outPathOpt) + guard try persistPayloadScreenshot(to: outputURL, allowFailure: false) else { + throw CLIError(message: "browser screenshot missing image data") + } + screenshotPath = outputURL.path + screenshotURL = outputURL.absoluteString + payload["path"] = screenshotPath + payload["url"] = screenshotURL + } else { + syncScreenshotLocationFields() + if !hasText(screenshotPath) && !hasText(screenshotURL) { + let outputDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-browser-screenshots-cli", isDirectory: true) + if (try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)) != nil { + bestEffortPruneTemporaryFiles(in: outputDir) + let timestampMs = Int(Date().timeIntervalSince1970 * 1000) + let safeSid = sanitizedFilenameComponent(sid) + let filename = "surface-\(safeSid)-\(timestampMs)-\(String(UUID().uuidString.prefix(8))).png" + let outputURL = outputDir.appendingPathComponent(filename, isDirectory: false) + if (try? persistPayloadScreenshot(to: outputURL, allowFailure: true)) == true { + screenshotPath = outputURL.path + screenshotURL = outputURL.absoluteString + payload["path"] = screenshotPath + payload["url"] = screenshotURL + } + } + } + } + + if outputAsJSON { + let formattedPayload = formatIDs(payload, mode: idFormat) + if var outputPayload = formattedPayload as? [String: Any] { + if hasText(screenshotPath) || hasText(screenshotURL) { + outputPayload.removeValue(forKey: "png_base64") + } + print(jsonString(outputPayload)) + } else { + print(jsonString(formattedPayload)) + } } else if let outPathOpt { print("OK \(outPathOpt)") + } else if let screenshotURL, + hasText(screenshotURL) { + print("OK \(screenshotURL)") + } else if let screenshotPath, + hasText(screenshotPath) { + print("OK \(screenshotPath)") } else { print("OK") } @@ -5511,8 +5693,10 @@ struct CMUXCLI { } private func jsonString(_ object: Any) -> String { + var options: JSONSerialization.WritingOptions = [.prettyPrinted] + options.insert(.withoutEscapingSlashes) guard JSONSerialization.isValidJSONObject(object), - let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]), + let data = try? JSONSerialization.data(withJSONObject: object, options: options), let output = String(data: data, encoding: .utf8) else { return "{}" } @@ -6797,6 +6981,7 @@ struct CMUXCLI { browser press|keydown|keyup [--snapshot-after] browser select [--snapshot-after] browser scroll [--selector ] [--dx ] [--dy ] [--snapshot-after] + browser screenshot [--out ] [--json] browser get [...] browser is browser find ... diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 85027ee4..643fc841 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -41,6 +41,9 @@ _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}" _CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}" _CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}" +_CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}" +_CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}" +_CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}" _CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}" _CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}" _CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}" @@ -51,6 +54,41 @@ _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" +_cmux_git_resolve_head_path() { + # Resolve the HEAD file path without invoking git (fast; works for worktrees). + local dir="$PWD" + while :; do + if [[ -d "$dir/.git" ]]; then + printf '%s\n' "$dir/.git/HEAD" + return 0 + fi + if [[ -f "$dir/.git" ]]; then + local line gitdir + IFS= read -r line < "$dir/.git" || line="" + if [[ "$line" == gitdir:* ]]; then + gitdir="${line#gitdir:}" + gitdir="${gitdir## }" + gitdir="${gitdir%% }" + [[ -n "$gitdir" ]] || return 1 + [[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir" + printf '%s\n' "$gitdir/HEAD" + return 0 + fi + fi + [[ "$dir" == "/" || -z "$dir" ]] && break + dir="$(dirname "$dir")" + done + return 1 +} + +_cmux_git_head_signature() { + local head_path="$1" + [[ -n "$head_path" && -r "$head_path" ]] || return 1 + local line + IFS= read -r line < "$head_path" || return 1 + printf '%s\n' "$line" +} + _cmux_report_tty_once() { # Send the TTY name to the app once per session so the batched port scanner # knows which TTY belongs to this panel. @@ -62,7 +100,7 @@ _cmux_report_tty_once() { _CMUX_TTY_REPORTED=1 { _cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & + } >/dev/null 2>&1 & disown } _cmux_ports_kick() { @@ -74,7 +112,7 @@ _cmux_ports_kick() { _CMUX_PORTS_LAST_RUN=$SECONDS { _cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & + } >/dev/null 2>&1 & disown } _cmux_prompt_command() { @@ -123,7 +161,26 @@ _cmux_prompt_command() { { local qpwd="${pwd//\"/\\\"}" _cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - } >/dev/null 2>&1 & + } >/dev/null 2>&1 & disown + fi + + # Branch can change via aliases/tools while an older probe is still in flight. + # Track .git/HEAD content so we can restart stale probes immediately. + local git_head_changed=0 + if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then + _CMUX_GIT_HEAD_LAST_PWD="$pwd" + _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)" + _CMUX_GIT_HEAD_SIGNATURE="" + fi + if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then + local head_signature + head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" + if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 + # Also invalidate the PR probe so it refreshes with the new branch. + _CMUX_PR_LAST_RUN=0 + fi fi # Git branch/dirty can change without a directory change (e.g. `git checkout`), @@ -131,7 +188,7 @@ _cmux_prompt_command() { # When pwd changes (cd into a different repo), kill the old probe and start fresh # so the sidebar picks up the new branch immediately. if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then + if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" || "$git_head_changed" == "1" ]]; then kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true _CMUX_GIT_JOB_PID="" _CMUX_GIT_JOB_STARTED_AT=0 @@ -154,20 +211,21 @@ _cmux_prompt_command() { fi } >/dev/null 2>&1 & _CMUX_GIT_JOB_PID=$! + disown _CMUX_GIT_JOB_STARTED_AT=$now fi # Pull request metadata (number/state/url): - # refresh on cwd change and periodically to avoid stale status. + # refresh on cwd change, HEAD change, and periodically to avoid stale status. if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]]; then kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true _CMUX_PR_JOB_PID="" _CMUX_PR_JOB_STARTED_AT=0 fi fi - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then _CMUX_PR_LAST_PWD="$pwd" _CMUX_PR_LAST_RUN=$now @@ -197,6 +255,7 @@ _cmux_prompt_command() { fi } >/dev/null 2>&1 & _CMUX_PR_JOB_PID=$! + disown _CMUX_PR_JOB_STARTED_AT=$now fi fi @@ -205,6 +264,7 @@ _cmux_prompt_command() { if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then _cmux_ports_kick fi + } _cmux_install_prompt_command() { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 988be2f1..f35814bc 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -45,9 +45,8 @@ typeset -g _CMUX_GIT_JOB_STARTED_AT=0 typeset -g _CMUX_GIT_FORCE=0 typeset -g _CMUX_GIT_HEAD_LAST_PWD="" typeset -g _CMUX_GIT_HEAD_PATH="" -typeset -g _CMUX_GIT_HEAD_MTIME=0 +typeset -g _CMUX_GIT_HEAD_SIGNATURE="" typeset -g _CMUX_GIT_HEAD_WATCH_PID="" -typeset -g _CMUX_HAVE_ZSTAT=0 typeset -g _CMUX_PR_LAST_PWD="" typeset -g _CMUX_PR_LAST_RUN=0 typeset -g _CMUX_PR_JOB_PID="" @@ -60,19 +59,6 @@ typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 -_cmux_ensure_zstat() { - # zstat is substantially cheaper than spawning external `stat`. - if (( _CMUX_HAVE_ZSTAT != 0 )); then - return 0 - fi - if zmodload -F zsh/stat b:zstat 2>/dev/null; then - _CMUX_HAVE_ZSTAT=1 - return 0 - fi - _CMUX_HAVE_ZSTAT=-1 - return 1 -} - _cmux_git_resolve_head_path() { # Resolve the HEAD file path without invoking git (fast; works for worktrees). local dir="$PWD" @@ -100,27 +86,15 @@ _cmux_git_resolve_head_path() { return 1 } -_cmux_git_head_mtime() { +_cmux_git_head_signature() { local head_path="$1" - [[ -n "$head_path" && -f "$head_path" ]] || { print -r -- 0; return 0; } - - if _cmux_ensure_zstat; then - typeset -A st - if zstat -H st +mtime -- "$head_path" 2>/dev/null; then - print -r -- "${st[mtime]:-0}" - return 0 - fi - fi - - # Fallback for environments where zsh/stat isn't available. - if command -v stat >/dev/null 2>&1; then - local mtime - mtime="$(stat -f %m "$head_path" 2>/dev/null || stat -c %Y "$head_path" 2>/dev/null || echo 0)" - print -r -- "$mtime" + [[ -n "$head_path" && -r "$head_path" ]] || return 1 + local line="" + if IFS= read -r line < "$head_path"; then + print -r -- "$line" return 0 fi - - print -r -- 0 + return 1 } _cmux_report_tty_once() { @@ -184,23 +158,23 @@ _cmux_start_git_head_watch() { watch_head_path="$(_cmux_git_resolve_head_path 2>/dev/null || true)" [[ -n "$watch_head_path" ]] || return 0 - local watch_head_mtime - watch_head_mtime="$(_cmux_git_head_mtime "$watch_head_path" 2>/dev/null || echo 0)" + local watch_head_signature + watch_head_signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)" _CMUX_GIT_HEAD_LAST_PWD="$watch_pwd" _CMUX_GIT_HEAD_PATH="$watch_head_path" - _CMUX_GIT_HEAD_MTIME="$watch_head_mtime" + _CMUX_GIT_HEAD_SIGNATURE="$watch_head_signature" _cmux_stop_git_head_watch { - local last_mtime="$watch_head_mtime" + local last_signature="$watch_head_signature" while true; do sleep 1 - local mtime - mtime="$(_cmux_git_head_mtime "$watch_head_path" 2>/dev/null || echo 0)" - if [[ -n "$mtime" && "$mtime" != 0 && "$mtime" != "$last_mtime" ]]; then - last_mtime="$mtime" + local signature + signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)" + if [[ -n "$signature" && "$signature" != "$last_signature" ]]; then + last_signature="$signature" _cmux_report_git_branch_for_path "$watch_pwd" fi done @@ -299,13 +273,13 @@ _cmux_precmd() { if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then _CMUX_GIT_HEAD_LAST_PWD="$pwd" _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)" - _CMUX_GIT_HEAD_MTIME=0 + _CMUX_GIT_HEAD_SIGNATURE="" fi if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then - local head_mtime - head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)" - if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then - _CMUX_GIT_HEAD_MTIME="$head_mtime" + local head_signature + head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" + if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then + _CMUX_GIT_HEAD_SIGNATURE="$head_signature" # Treat HEAD file change like a git command — force-replace any # running probe so the sidebar picks up the new branch immediately. _CMUX_GIT_FORCE=1 diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 07a7ba8b..b015a5ea 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1005,14 +1005,31 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown( func shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: NSEvent.ModifierFlags, chars: String, - keyCode: UInt16 + keyCode: UInt16, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) ) -> Bool { let normalizedFlags = flags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) guard normalizedFlags == [.command, .control] else { return false } let normalizedChars = chars.lowercased() - return normalizedChars == "f" || keyCode == 3 + if normalizedChars == "f" { + return true + } + let charsAreControlSequence = !normalizedChars.isEmpty + && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } + if !normalizedChars.isEmpty && !charsAreControlSequence { + return false + } + + // Fallback to layout translation only when characters are unavailable (for + // synthetic/key-equivalent paths that can report an empty string). + if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { + return translatedCharacter == "f" + } + + // Keep ANSI fallback as a final safety net when layout translation is unavailable. + return keyCode == 3 } func commandPaletteSelectionDeltaForKeyboardNavigation( @@ -1449,6 +1466,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent weak var sidebarState: SidebarState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? + var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) private var workspaceObserver: NSObjectProtocol? private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] private var windowKeyObserver: NSObjectProtocol? @@ -1559,7 +1577,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var isApplyingStartupSessionRestore = false private var sessionAutosaveTimer: DispatchSourceTimer? private var socketListenerHealthTimer: DispatchSourceTimer? - private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(5) + private var socketListenerHealthCheckInFlight = false + private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60 private let sessionPersistenceQueue = DispatchQueue( @@ -1819,7 +1838,6 @@ 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 { @@ -2491,25 +2509,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func stopSocketListenerHealthMonitor() { socketListenerHealthTimer?.cancel() socketListenerHealthTimer = nil + socketListenerHealthCheckInFlight = false } private func restartSocketListenerIfNeededForHealthCheck(source: String) { - guard let config = socketListenerConfigurationIfEnabled() else { return } - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) + guard !socketListenerHealthCheckInFlight, + let config = socketListenerConfigurationIfEnabled() else { return } + let expectedSocketPath = config.path + let terminalController = TerminalController.shared + socketListenerHealthCheckInFlight = true + Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in + let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath) + Task { @MainActor [weak self, health] in + guard let self else { return } + self.socketListenerHealthCheckInFlight = false + self.handleSocketListenerHealthCheckResult( + health, + source: source, + expectedSocketPath: expectedSocketPath + ) + } + } + } + + private func handleSocketListenerHealthCheckResult( + _ health: TerminalController.SocketListenerHealth, + source: String, + expectedSocketPath: String + ) { + guard let config = socketListenerConfigurationIfEnabled(), + config.path == expectedSocketPath else { return } guard !health.isHealthy else { lastSocketListenerUnhealthyCaptureAt = .distantPast return } let failureSignals = health.failureSignals - let data: [String: Any] = [ + var data: [String: Any] = [ "source": source, "path": config.path, "isRunning": health.isRunning ? 1 : 0, "acceptLoopAlive": health.acceptLoopAlive ? 1 : 0, "socketPathMatches": health.socketPathMatches ? 1 : 0, "socketPathExists": health.socketPathExists ? 1 : 0, + "socketProbePerformed": health.socketProbePerformed ? 1 : 0, "failureSignals": failureSignals ] + if let socketConnectable = health.socketConnectable { + data["socketConnectable"] = socketConnectable ? 1 : 0 + } + if let socketConnectErrno = health.socketConnectErrno { + data["socketConnectErrno"] = Int(socketConnectErrno) + } sentryBreadcrumb("socket.listener.unhealthy", category: "socket", data: data) let now = Date() if now.timeIntervalSince(lastSocketListenerUnhealthyCaptureAt) >= Self.socketListenerUnhealthyCaptureCooldown { @@ -5482,6 +5532,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func isGotoSplitUITestRecordingEnabled() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" || env["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] == "1" + } + + private func gotoSplitUITestDataPath() -> String? { + guard isGotoSplitUITestRecordingEnabled() else { return nil } + let env = ProcessInfo.processInfo.environment + guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil } + return path + } + + private func gotoSplitFindStateSnapshot(for workspace: Workspace) -> [String: String] { + var updates: [String: String] = [ + "focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? "" + ] + + if let focusedPanelId = workspace.focusedPanelId { + updates["focusedPanelId"] = focusedPanelId.uuidString + if let terminal = workspace.terminalPanel(for: focusedPanelId) { + updates["focusedPanelKind"] = "terminal" + updates["focusedTerminalFindNeedle"] = terminal.searchState?.needle ?? "" + updates["focusedBrowserFindNeedle"] = "" + } else if let browser = workspace.browserPanel(for: focusedPanelId) { + updates["focusedPanelKind"] = "browser" + updates["focusedBrowserFindNeedle"] = browser.searchState?.needle ?? "" + updates["focusedTerminalFindNeedle"] = "" + } else { + updates["focusedPanelKind"] = "other" + updates["focusedTerminalFindNeedle"] = "" + updates["focusedBrowserFindNeedle"] = "" + } + } else { + updates["focusedPanelId"] = "" + updates["focusedPanelKind"] = "none" + updates["focusedTerminalFindNeedle"] = "" + updates["focusedBrowserFindNeedle"] = "" + } + + let terminalWithFind = workspace.panels.values + .compactMap { $0 as? TerminalPanel } + .first(where: { $0.searchState != nil }) + updates["terminalFindPanelId"] = terminalWithFind?.id.uuidString ?? "" + updates["terminalFindNeedle"] = terminalWithFind?.searchState?.needle ?? "" + + let browserWithFind = workspace.panels.values + .compactMap { $0 as? BrowserPanel } + .first(where: { $0.searchState != nil }) + updates["browserFindPanelId"] = browserWithFind?.id.uuidString ?? "" + updates["browserFindNeedle"] = browserWithFind?.searchState?.needle ?? "" + + return updates + } + private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) { let maxAttempts = 120 guard attempt < maxAttempts else { @@ -5603,10 +5707,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) { - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return } - guard let tabManager, - let focusedPaneId = tabManager.selectedWorkspace?.bonsplitController.focusedPaneId else { return } + guard isGotoSplitUITestRecordingEnabled() else { return } + guard let tabManager, let workspace = tabManager.selectedWorkspace else { return } let directionValue: String switch direction { @@ -5620,15 +5722,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent directionValue = "down" } - writeGotoSplitTestData([ - "lastMoveDirection": directionValue, - "focusedPaneId": focusedPaneId.description - ]) + var updates = gotoSplitFindStateSnapshot(for: workspace) + updates["lastMoveDirection"] = directionValue + writeGotoSplitTestData(updates) } private func recordGotoSplitSplitIfNeeded(direction: SplitDirection) { - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return } + guard isGotoSplitUITestRecordingEnabled() else { return } guard let workspace = tabManager?.selectedWorkspace else { return } let directionValue: String @@ -5643,16 +5743,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent directionValue = "down" } - writeGotoSplitTestData([ - "lastSplitDirection": directionValue, - "paneCountAfterSplit": String(workspace.bonsplitController.allPaneIds.count), - "focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? "" - ]) + var updates = gotoSplitFindStateSnapshot(for: workspace) + updates["lastSplitDirection"] = directionValue + updates["paneCountAfterSplit"] = String(workspace.bonsplitController.allPaneIds.count) + writeGotoSplitTestData(updates) } private func writeGotoSplitTestData(_ updates: [String: String]) { - let env = ProcessInfo.processInfo.environment - guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return } + guard let path = gotoSplitUITestDataPath() else { return } var payload = loadGotoSplitTestData(at: path) for (key, value) in updates { payload[key] = value @@ -5792,6 +5890,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) } + @discardableResult + func dismissNotificationsPopoverIfShown() -> Bool { + titlebarAccessoryController.dismissNotificationsPopoverIfShown() + } + func jumpToLatestUnread() { guard let notificationStore else { return } #if DEBUG @@ -6103,7 +6206,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func handleCustomShortcut(event: NSEvent) -> Bool { // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. - // Most shortcuts below use keyCode fallbacks, so treat nil as "" rather than bailing out. + // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. let chars = (event.charactersIgnoringModifiers ?? "").lowercased() let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let hasControl = flags.contains(.control) @@ -6140,7 +6243,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Special-case: Cmd+D should confirm destructive close on alerts. // XCUITest key events often hit the app-level local monitor first, so forward the key // equivalent to the alert panel explicitly. - if flags == [.command], chars == "d", + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) + ), let root = closeConfirmationPanel.contentView, let closeButton = findButton(in: root, titled: "Close") { closeButton.performClick(nil) @@ -6315,15 +6421,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // focused omnibar in another window does not suppress Cmd+P here. let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil let isCommandP = !hasFocusedAddressBarInShortcutContext - && normalizedFlags == [.command] - && (chars == "p" || event.keyCode == 35) + && matchShortcut( + event: event, + shortcut: StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) + ) if isCommandP { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.cmdP") return true } - let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35) + let isCommandShiftP = matchShortcut( + event: event, + shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) + ) if isCommandShiftP { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP") @@ -6339,11 +6450,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if normalizedFlags == [.command], chars == "q" { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) + ) { return handleQuitShortcutWarning() } - if normalizedFlags == [.command, .shift], - (chars == "," || chars == "<" || event.keyCode == 43) { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) + ) { GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma") return true } @@ -6563,7 +6679,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - if normalizedFlags == [.command, .option], (chars == "t" || event.keyCode == 17) { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "t", command: true, shift: false, option: true, control: false) + ) { if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) @@ -6584,7 +6703,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Cmd+W must close the focused panel even if first-responder momentarily lags on a // browser NSTextView during split focus transitions. - if normalizedFlags == [.command], (chars == "w" || event.keyCode == 13) { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) + ) { if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) @@ -6813,7 +6935,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Focus browser address bar: Cmd+L - if flags == [.command] && chars == "l" { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "l", command: true, shift: false, option: false, control: false) + ) { if let focusedPanel = tabManager?.focusedBrowserPanel { focusBrowserAddressBar(in: focusedPanel) return true @@ -7401,42 +7526,127 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - /// Match a shortcut against an event, handling normal keys + /// Match a shortcut against an event, handling normal keys. private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { // Some keys can include extra flags (e.g. .function) depending on the responder chain. // Strip those for consistent matching across first responders (terminal, WebKit, etc). let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + .subtracting([.numericPad, .function, .capsLock]) guard flags == shortcut.modifierFlags else { return false } - // NSEvent.charactersIgnoringModifiers preserves Shift for some symbol keys - // (e.g. Shift+] can yield "}" instead of "]"), so match brackets by keyCode. let shortcutKey = shortcut.key.lowercased() if shortcutKey == "\r" { return event.keyCode == 36 || event.keyCode == 76 } - if shortcutKey == "[" || shortcutKey == "]" { - switch event.keyCode { - case 33: // kVK_ANSI_LeftBracket - return shortcutKey == "[" - case 30: // kVK_ANSI_RightBracket - return shortcutKey == "]" - default: - return false - } - } - // Control-key combos can produce control characters (e.g. Ctrl+H => backspace), - // so fall back to keyCode matching for common printable keys. - if let chars = event.charactersIgnoringModifiers?.lowercased(), chars == shortcutKey { + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + if shortcutCharacterMatches( + eventCharacter: eventCharsIgnoringModifiers, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { return true } - if let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { + + // For command-based shortcuts, trust AppKit's layout-aware characters when present. + // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, + // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. + let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + if hasEventChars, + flags.contains(.command), + !flags.contains(.control), + shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { + return false + } + + // Match using the current keyboard layout so Command shortcuts stay character-based + // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if shortcutCharacterMatches( + eventCharacter: layoutCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return true + } + + // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), + // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for + // command punctuation shortcuts, since some non-US layouts report different characters + // for the same physical key even when menu-equivalent semantics should still apply. + let allowANSIKeyCodeFallback = flags.contains(.control) + || (flags.contains(.command) + && !flags.contains(.control) + && ( + !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) + )) + if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { return event.keyCode == expectedKeyCode } return false } + private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { + guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { + return false + } + return CharacterSet.letters.contains(scalar) + } + + private func shortcutCharacterMatches( + eventCharacter: String?, + shortcutKey: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Bool { + guard let eventCharacter, !eventCharacter.isEmpty else { return false } + if normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) == shortcutKey { + return true + } + return false + } + + private func normalizedShortcutEventCharacter( + _ eventCharacter: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> String { + let lowered = eventCharacter.lowercased() + guard applyShiftSymbolNormalization else { return lowered } + + switch lowered { + case "{": return "[" + case "}": return "]" + case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma + case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period + case "?": return "/" + case ":": return ";" + case "\"": return "'" + case "|": return "\\" + case "~": return "`" + case "+": return "=" + case "_": return "-" + case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 + case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 + case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 + case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 + case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 + case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 + case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 + case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 + case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 + case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 + default: return lowered + } + } + private func keyCodeForShortcutKey(_ key: String) -> UInt16? { // Matches macOS ANSI key codes. This is intentionally limited to keys we // support in StoredShortcut/ghostty trigger translation. @@ -7470,8 +7680,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case "-": return 27 // kVK_ANSI_Minus case "8": return 28 // kVK_ANSI_8 case "0": return 29 // kVK_ANSI_0 + case "]": return 30 // kVK_ANSI_RightBracket case "o": return 31 // kVK_ANSI_O case "u": return 32 // kVK_ANSI_U + case "[": return 33 // kVK_ANSI_LeftBracket case "i": return 34 // kVK_ANSI_I case "p": return 35 // kVK_ANSI_P case "l": return 37 // kVK_ANSI_L @@ -7943,16 +8155,6 @@ 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, @@ -8006,15 +8208,6 @@ 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"]) @@ -8074,22 +8267,6 @@ 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, diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index d53e1a71..da7be546 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1,9 +1,7 @@ import AppKit +import Bonsplit import ObjectiveC import WebKit -#if DEBUG -import Bonsplit -#endif private var cmuxWindowBrowserPortalKey: UInt8 = 0 private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0 @@ -126,6 +124,17 @@ final class WindowBrowserHostView: NSView { if shouldPassThroughToSplitDivider(at: point) { 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 + // report a pressed-button state, so include that path here. + if Self.shouldPassThroughToDragTargets( + pasteboardTypes: NSPasteboard(name: .drag).types, + eventType: NSApp.currentEvent?.type + ) { + return nil + } let hitView = super.hitTest(point) return hitView === self ? nil : hitView } @@ -227,6 +236,31 @@ final class WindowBrowserHostView: NSView { splitDividerCursorKind(at: point) != nil } + static func shouldPassThroughToDragTargets( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + if DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting( + pasteboardTypes: pasteboardTypes, + eventType: eventType + ) { + return true + } + + guard let eventType else { return false } + switch eventType { + case .cursorUpdate, .mouseEntered, .mouseExited, .mouseMoved: + // Browser-side tab drags can surface as hover events with a mixed + // pasteboard payload (tabtransfer plus promised-file UTIs). Prefer + // the explicit Bonsplit drag types so WKWebView cannot steal the + // session as a file upload. + return DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) + default: + return false + } + } + private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? { guard !view.isHidden else { return nil } @@ -317,8 +351,329 @@ final class WindowBrowserHostView: NSView { } +private final class BrowserDropZoneOverlayView: NSView { + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + +struct BrowserPaneDropContext: Equatable { + let workspaceId: UUID + let panelId: UUID + let paneId: PaneID +} + +struct BrowserPaneDragTransfer: Equatable { + let tabId: UUID + let sourcePaneId: UUID + let sourceProcessId: Int32 + + var isFromCurrentProcess: Bool { + sourceProcessId == Int32(ProcessInfo.processInfo.processIdentifier) + } + + static func decode(from pasteboard: NSPasteboard) -> BrowserPaneDragTransfer? { + if let data = pasteboard.data(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { + return decode(from: data) + } + if let raw = pasteboard.string(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { + return decode(from: Data(raw.utf8)) + } + return nil + } + + static func decode(from data: Data) -> BrowserPaneDragTransfer? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tab = json["tab"] as? [String: Any], + let tabIdRaw = tab["id"] as? String, + let tabId = UUID(uuidString: tabIdRaw), + let sourcePaneIdRaw = json["sourcePaneId"] as? String, + let sourcePaneId = UUID(uuidString: sourcePaneIdRaw) else { + return nil + } + + let sourceProcessId = (json["sourceProcessId"] as? NSNumber)?.int32Value ?? -1 + return BrowserPaneDragTransfer( + tabId: tabId, + sourcePaneId: sourcePaneId, + sourceProcessId: sourceProcessId + ) + } +} + +struct BrowserPaneSplitTarget: Equatable { + let orientation: SplitOrientation + let insertFirst: Bool +} + +enum BrowserPaneDropAction: Equatable { + case noOp + case move( + tabId: UUID, + targetWorkspaceId: UUID, + targetPane: PaneID, + splitTarget: BrowserPaneSplitTarget? + ) +} + +enum BrowserPaneDropRouting { + static func zone(for location: CGPoint, in size: CGSize) -> DropZone { + let edgeRatio: CGFloat = 0.25 + let horizontalEdge = max(80, size.width * edgeRatio) + let verticalEdge = max(80, size.height * edgeRatio) + + if location.x < horizontalEdge { + return .left + } else if location.x > size.width - horizontalEdge { + return .right + } else if location.y > size.height - verticalEdge { + return .top + } else if location.y < verticalEdge { + return .bottom + } else { + return .center + } + } + + static func action( + for transfer: BrowserPaneDragTransfer, + target: BrowserPaneDropContext, + zone: DropZone + ) -> BrowserPaneDropAction? { + if zone == .center, transfer.sourcePaneId == target.paneId.id { + return .noOp + } + + let splitTarget: BrowserPaneSplitTarget? + switch zone { + case .center: + splitTarget = nil + case .left: + splitTarget = BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: true) + case .right: + splitTarget = BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) + case .top: + splitTarget = BrowserPaneSplitTarget(orientation: .vertical, insertFirst: true) + case .bottom: + splitTarget = BrowserPaneSplitTarget(orientation: .vertical, insertFirst: false) + } + + return .move( + tabId: transfer.tabId, + targetWorkspaceId: target.workspaceId, + targetPane: target.paneId, + splitTarget: splitTarget + ) + } +} + +final class BrowserPaneDropTargetView: NSView { + weak var slotView: WindowBrowserSlotView? + var dropContext: BrowserPaneDropContext? + private var activeZone: DropZone? +#if DEBUG + private var lastHitTestSignature: String? +#endif + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([DragOverlayRoutingPolicy.bonsplitTabTransferType]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + static func shouldCaptureHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false } + guard let eventType else { return false } + + switch eventType { + case .cursorUpdate, + .mouseEntered, + .mouseExited, + .mouseMoved, + .leftMouseDragged, + .rightMouseDragged, + .otherMouseDragged, + .appKitDefined, + .applicationDefined, + .systemDefined, + .periodic: + return true + default: + return false + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point), dropContext != nil else { return nil } + + let pasteboardTypes = NSPasteboard(name: .drag).types + let eventType = NSApp.currentEvent?.type + let capture = Self.shouldCaptureHitTesting( + pasteboardTypes: pasteboardTypes, + eventType: eventType + ) +#if DEBUG + logHitTestDecision(capture: capture, pasteboardTypes: pasteboardTypes, eventType: eventType) +#endif + return capture ? self : nil + } + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDragState(sender, phase: "entered") + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDragState(sender, phase: "updated") + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + clearDragState(phase: "exited") + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + defer { + clearDragState(phase: "perform.clear") + } + + guard let dropContext, + let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess else { +#if DEBUG + dlog("browser.paneDrop.perform allowed=0 reason=missingTransfer") +#endif + return false + } + + let location = convert(sender.draggingLocation, from: nil) + let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size) + guard let action = BrowserPaneDropRouting.action( + for: transfer, + target: dropContext, + zone: zone + ) else { +#if DEBUG + dlog( + "browser.paneDrop.perform allowed=0 panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "reason=noAction zone=\(zone)" + ) +#endif + return false + } + + switch action { + case .noOp: +#if DEBUG + dlog( + "browser.paneDrop.perform allowed=1 panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(transfer.tabId.uuidString.prefix(5)) action=noop" + ) +#endif + return true + case .move(let tabId, let workspaceId, let targetPane, let splitTarget): + let moved = AppDelegate.shared?.moveBonsplitTab( + tabId: tabId, + toWorkspace: workspaceId, + targetPane: targetPane, + splitTarget: splitTarget.map { ($0.orientation, $0.insertFirst) }, + focus: true, + focusWindow: true + ) ?? false +#if DEBUG + let splitLabel = splitTarget.map { + "\($0.orientation.rawValue):\($0.insertFirst ? 1 : 0)" + } ?? "none" + dlog( + "browser.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuidString.prefix(5)) zone=\(zone) pane=\(targetPane.id.uuidString.prefix(5)) " + + "split=\(splitLabel) moved=\(moved ? 1 : 0)" + ) +#endif + return moved + } + } + + private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + guard let dropContext, + let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess else { + clearDragState(phase: "\(phase).reject") + return [] + } + + let location = convert(sender.draggingLocation, from: nil) + let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size) + activeZone = zone + slotView?.setPortalDragDropZone(zone) +#if DEBUG + dlog( + "browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)" + ) +#endif + return .move + } + + private func clearDragState(phase: String) { + guard activeZone != nil else { return } + activeZone = nil + slotView?.setPortalDragDropZone(nil) +#if DEBUG + if let dropContext { + dlog( + "browser.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none" + ) + } +#endif + } + +#if DEBUG + private func logHitTestDecision( + capture: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) { + let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + guard hasTransferType || capture else { return } + + let signature = [ + capture ? "1" : "0", + hasTransferType ? "1" : "0", + String(describing: dropContext != nil), + eventType.map { String($0.rawValue) } ?? "nil", + ].joined(separator: "|") + guard lastHitTestSignature != signature else { return } + lastHitTestSignature = signature + + let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-" + dlog( + "browser.paneDrop.hitTest capture=\(capture ? 1 : 0) " + + "hasTransfer=\(hasTransferType ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " + + "event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)" + ) + } +#endif +} + final class WindowBrowserSlotView: NSView { override var isOpaque: Bool { false } + private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero) + private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero) + private var forwardedDropZone: DropZone? + private var portalDragDropZone: DropZone? + private var displayedDropZone: DropZone? + private var dropZoneOverlayAnimationGeneration: UInt64 = 0 + private var isRefreshingInteractionLayers = false override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -326,16 +681,193 @@ final class WindowBrowserSlotView: NSView { layer?.masksToBounds = true translatesAutoresizingMaskIntoConstraints = true autoresizingMask = [] + + paneDropTargetView.slotView = self + + dropZoneOverlayView.wantsLayer = true + dropZoneOverlayView.layer?.backgroundColor = cmuxAccentNSColor().withAlphaComponent(0.25).cgColor + dropZoneOverlayView.layer?.borderColor = cmuxAccentNSColor().cgColor + dropZoneOverlayView.layer?.borderWidth = 2 + dropZoneOverlayView.layer?.cornerRadius = 8 + dropZoneOverlayView.isHidden = true + addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) + addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) } @available(*, unavailable) required init?(coder: NSCoder) { nil } + + override func layout() { + super.layout() + paneDropTargetView.frame = bounds + applyResolvedDropZoneOverlay() + } + + func setDropZoneOverlay(zone: DropZone?) { + forwardedDropZone = zone + applyResolvedDropZoneOverlay() + } + + func setPortalDragDropZone(_ zone: DropZone?) { + portalDragDropZone = zone + applyResolvedDropZoneOverlay() + } + + func setPaneDropContext(_ context: BrowserPaneDropContext?) { + paneDropTargetView.dropContext = context + } + + override func didAddSubview(_ subview: NSView) { + super.didAddSubview(subview) + guard subview !== paneDropTargetView, subview !== dropZoneOverlayView else { return } + bringInteractionLayersToFrontIfNeeded() + } + + private var activeDropZone: DropZone? { + portalDragDropZone ?? forwardedDropZone + } + + private func applyResolvedDropZoneOverlay() { + let resolvedZone = activeDropZone + if resolvedZone != nil, (bounds.width <= 2 || bounds.height <= 2) { + bringInteractionLayersToFrontIfNeeded() + return + } + + let previousZone = displayedDropZone + displayedDropZone = resolvedZone + let previousFrame = dropZoneOverlayView.frame + + guard let zone = resolvedZone else { + guard !dropZoneOverlayView.isHidden else { + bringInteractionLayersToFrontIfNeeded() + return + } + + dropZoneOverlayAnimationGeneration &+= 1 + let animationGeneration = dropZoneOverlayAnimationGeneration + dropZoneOverlayView.layer?.removeAllAnimations() + bringInteractionLayersToFrontIfNeeded() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + dropZoneOverlayView.animator().alphaValue = 0 + } completionHandler: { [weak self] in + guard let self else { return } + guard self.dropZoneOverlayAnimationGeneration == animationGeneration else { return } + guard self.displayedDropZone == nil else { return } + self.dropZoneOverlayView.isHidden = true + self.dropZoneOverlayView.alphaValue = 1 + } + return + } + + let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size) + let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame) + let zoneChanged = previousZone != zone + + if !dropZoneOverlayView.isHidden && !needsFrameUpdate && !zoneChanged { + bringInteractionLayersToFrontIfNeeded() + return + } + + dropZoneOverlayAnimationGeneration &+= 1 + dropZoneOverlayView.layer?.removeAllAnimations() + + if dropZoneOverlayView.isHidden { + applyDropZoneOverlayFrame(targetFrame) + dropZoneOverlayView.alphaValue = 0 + dropZoneOverlayView.isHidden = false + bringInteractionLayersToFrontIfNeeded() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + dropZoneOverlayView.animator().alphaValue = 1 + } + return + } + + bringInteractionLayersToFrontIfNeeded() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + if needsFrameUpdate { + dropZoneOverlayView.animator().frame = targetFrame + } + if dropZoneOverlayView.alphaValue < 1 { + dropZoneOverlayView.animator().alphaValue = 1 + } + } + } + + private func interactionLayerPriority(of view: NSView) -> Int { + if view === paneDropTargetView { return 1 } + if view === dropZoneOverlayView { return 2 } + return 0 + } + + private func bringInteractionLayersToFrontIfNeeded() { + guard !isRefreshingInteractionLayers else { return } + isRefreshingInteractionLayers = true + defer { isRefreshingInteractionLayers = false } + + if paneDropTargetView.superview !== self { + addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) + } + if dropZoneOverlayView.superview !== self { + addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) + } + + let context = Unmanaged.passUnretained(self).toOpaque() + sortSubviews({ lhs, rhs, context in + guard let context else { return .orderedSame } + let slotView = Unmanaged.fromOpaque(context).takeUnretainedValue() + let lhsPriority = slotView.interactionLayerPriority(of: lhs) + let rhsPriority = slotView.interactionLayerPriority(of: rhs) + if lhsPriority == rhsPriority { return .orderedSame } + return lhsPriority < rhsPriority ? .orderedAscending : .orderedDescending + }, context: context) + } + + private func applyDropZoneOverlayFrame(_ frame: CGRect) { + if Self.rectApproximatelyEqual(dropZoneOverlayView.frame, frame) { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + dropZoneOverlayView.frame = frame + CATransaction.commit() + } + + 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) + } + } + + private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> 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 + } } @MainActor final class WindowBrowserPortal: NSObject { + private static let transientRecoveryRetryBudget: Int = 12 + private weak var window: NSWindow? private let hostView = WindowBrowserHostView(frame: .zero) private weak var installedContainerView: NSView? @@ -350,6 +882,9 @@ final class WindowBrowserPortal: NSObject { weak var anchorView: NSView? var visibleInUI: Bool var zPriority: Int + var dropZone: DropZone? + var paneDropContext: BrowserPaneDropContext? + var transientRecoveryRetriesRemaining: Int } private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] @@ -427,22 +962,39 @@ final class WindowBrowserPortal: NSObject { hostView.superview?.layoutSubtreeIfNeeded() hostView.layoutSubtreeIfNeeded() synchronizeAllWebViews(excluding: nil, source: "externalGeometry") + + for entry in entriesByWebViewId.values { + guard let webView = entry.webView, + let containerView = entry.containerView, + !containerView.isHidden else { continue } + refreshHostedWebViewPresentation( + webView, + in: containerView, + reason: "externalGeometry" + ) + } } @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } guard let (container, reference) = installationTarget(for: window) else { return false } + let placementReference = preferredHostPlacementReference(in: container, fallback: reference) if hostView.superview !== container || installedContainerView !== container || installedReferenceView !== reference { hostView.removeFromSuperview() - container.addSubview(hostView, positioned: .above, relativeTo: reference) + container.addSubview(hostView, positioned: .above, relativeTo: placementReference) installedContainerView = container installedReferenceView = reference - } else if !Self.isView(hostView, above: reference, in: container) { - container.addSubview(hostView, positioned: .above, relativeTo: reference) + } else { + let aboveReference = Self.isView(hostView, above: reference, in: container) + let abovePlacementReference = placementReference === reference + || Self.isView(hostView, above: placementReference, in: container) + if !aboveReference || !abovePlacementReference { + container.addSubview(hostView, positioned: .above, relativeTo: placementReference) + } } synchronizeHostFrameToReference() @@ -526,6 +1078,30 @@ final class WindowBrowserPortal: NSObject { ) } + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. + /// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the + /// visible split pane during rearrangement; intersecting through ancestor bounds keeps the + /// portal locked to the pane the user can actually see. + private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect { + var frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + var current = anchorView.superview + while let ancestor = current { + let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil) + let finiteAncestorBounds = + ancestorBoundsInWindow.origin.x.isFinite && + ancestorBoundsInWindow.origin.y.isFinite && + ancestorBoundsInWindow.size.width.isFinite && + ancestorBoundsInWindow.size.height.isFinite + if finiteAncestorBounds { + frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow) + if frameInWindow.isNull { return .zero } + } + if ancestor === installedReferenceView { break } + current = ancestor.superview + } + return frameInWindow + } + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { frame.minX < bounds.minX - epsilon || frame.minY < bounds.minY - epsilon || @@ -557,11 +1133,19 @@ final class WindowBrowserPortal: NSObject { return viewIndex > referenceIndex } + private func preferredHostPlacementReference(in container: NSView, fallback reference: NSView) -> NSView { + container.subviews.last(where: { + $0 !== hostView && ($0 === reference || $0 is WindowTerminalHostView) + }) ?? reference + } + private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView { if let existing = entry.containerView { + existing.setPaneDropContext(entry.paneDropContext) return existing } let created = WindowBrowserSlotView(frame: .zero) + created.setPaneDropContext(entry.paneDropContext) #if DEBUG dlog( "browser.portal.container.create web=\(browserPortalDebugToken(webView)) " + @@ -571,6 +1155,48 @@ final class WindowBrowserPortal: NSObject { return created } + private func refreshHostedWebViewPresentation( + _ webView: WKWebView, + in containerView: WindowBrowserSlotView, + reason: String + ) { + guard !containerView.isHidden else { return } + + containerView.needsLayout = true + containerView.needsDisplay = true + containerView.setNeedsDisplay(containerView.bounds) + + if let scrollView = webView.enclosingScrollView { + scrollView.needsLayout = true + scrollView.needsDisplay = true + scrollView.setNeedsDisplay(scrollView.bounds) + } + + webView.needsLayout = true + webView.needsDisplay = true + webView.setNeedsDisplay(webView.bounds) + DispatchQueue.main.async { [weak self, weak webView, weak containerView] in + guard let self, let webView, let containerView, !containerView.isHidden else { return } + + containerView.layoutSubtreeIfNeeded() + if let scrollView = webView.enclosingScrollView { + scrollView.layoutSubtreeIfNeeded() + scrollView.displayIfNeeded() + } + webView.layoutSubtreeIfNeeded() + containerView.displayIfNeeded() + webView.displayIfNeeded() + (webView.window ?? self.hostView.window)?.displayIfNeeded() +#if DEBUG + dlog( + "browser.portal.refresh web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) reason=\(reason) " + + "frame=\(browserPortalDebugFrame(containerView.frame))" + ) +#endif + } + } + private func moveWebKitRelatedSubviewsIfNeeded( from sourceSuperview: NSView, to containerView: WindowBrowserSlotView, @@ -641,6 +1267,20 @@ final class WindowBrowserPortal: NSObject { entriesByWebViewId[webViewId] = entry } + func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.dropZone = zone + entriesByWebViewId[webViewId] = entry + entry.containerView?.setDropZoneOverlay(zone: zone) + } + + func updatePaneDropContext(forWebViewId webViewId: ObjectIdentifier, context: BrowserPaneDropContext?) { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.paneDropContext = context + entriesByWebViewId[webViewId] = entry + entry.containerView?.setPaneDropContext(context) + } + func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -648,7 +1288,16 @@ final class WindowBrowserPortal: NSObject { let anchorId = ObjectIdentifier(anchorView) let previousEntry = entriesByWebViewId[webViewId] let containerView = ensureContainerView( - for: previousEntry ?? Entry(webView: nil, containerView: nil, anchorView: nil, visibleInUI: false, zPriority: 0), + for: previousEntry ?? Entry( + webView: nil, + containerView: nil, + anchorView: nil, + visibleInUI: false, + zPriority: 0, + dropZone: nil, + paneDropContext: nil, + transientRecoveryRetriesRemaining: 0 + ), webView: webView ) @@ -677,7 +1326,10 @@ final class WindowBrowserPortal: NSObject { containerView: containerView, anchorView: anchorView, visibleInUI: visibleInUI, - zPriority: zPriority + zPriority: zPriority, + dropZone: previousEntry?.dropZone, + paneDropContext: previousEntry?.paneDropContext, + transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0 ) let didChangeAnchor: Bool = { @@ -747,7 +1399,11 @@ final class WindowBrowserPortal: NSObject { hostView.addSubview(containerView, positioned: .above, relativeTo: nil) } - synchronizeWebView(withId: webViewId, source: "bind") + synchronizeWebView( + withId: webViewId, + source: "bind", + forcePresentationRefresh: didChangeAnchor + ) pruneDeadEntries() } @@ -789,9 +1445,44 @@ final class WindowBrowserPortal: NSObject { } } - private func synchronizeWebView(withId webViewId: ObjectIdentifier, source: String) { + private func resetTransientRecoveryRetryIfNeeded(forWebViewId webViewId: ObjectIdentifier, entry: inout Entry) { + guard entry.transientRecoveryRetriesRemaining != 0 else { return } + entry.transientRecoveryRetriesRemaining = 0 + entriesByWebViewId[webViewId] = entry + } + + private func scheduleTransientRecoveryRetryIfNeeded( + forWebViewId webViewId: ObjectIdentifier, + entry: inout Entry, + webView: WKWebView, + reason: String + ) -> Bool { + if entry.transientRecoveryRetriesRemaining == 0 { + entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget + } + guard entry.transientRecoveryRetriesRemaining > 0 else { return false } + + entry.transientRecoveryRetriesRemaining -= 1 + entriesByWebViewId[webViewId] = entry +#if DEBUG + dlog( + "browser.portal.sync.deferRecover web=\(browserPortalDebugToken(webView)) " + + "reason=\(reason) remaining=\(entry.transientRecoveryRetriesRemaining)" + ) +#endif + if entry.transientRecoveryRetriesRemaining > 0 { + scheduleDeferredFullSynchronizeAll() + } + return true + } + + private func synchronizeWebView( + withId webViewId: ObjectIdentifier, + source: String, + forcePresentationRefresh: Bool = false + ) { guard ensureInstalled() else { return } - guard let entry = entriesByWebViewId[webViewId] else { return } + guard var entry = entriesByWebViewId[webViewId] else { return } guard let webView = entry.webView else { entriesByWebViewId.removeValue(forKey: webViewId) return @@ -804,6 +1495,16 @@ final class WindowBrowserPortal: NSObject { return } guard let anchorView = entry.anchorView, let window else { + if entry.visibleInUI { + _ = scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: "missingAnchorOrWindow" + ) + } else { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } #if DEBUG if !containerView.isHidden { dlog( @@ -812,6 +1513,7 @@ final class WindowBrowserPortal: NSObject { ) } #endif + containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true return } @@ -825,10 +1527,22 @@ final class WindowBrowserPortal: NSObject { ) } #endif + if entry.visibleInUI { + _ = scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: "anchorWindowMismatch" + ) + } else { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } + containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true return } + var refreshReasons: [String] = [] if containerView.superview !== hostView { #if DEBUG dlog( @@ -837,6 +1551,7 @@ final class WindowBrowserPortal: NSObject { ) #endif hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + refreshReasons.append("syncAttachContainer") } if webView.superview !== containerView { #if DEBUG @@ -859,12 +1574,11 @@ final class WindowBrowserPortal: NSObject { webView.translatesAutoresizingMaskIntoConstraints = true webView.autoresizingMask = [.width, .height] webView.frame = containerView.bounds - webView.needsLayout = true - webView.layoutSubtreeIfNeeded() + refreshReasons.append("syncAttachWebView") } _ = synchronizeHostFrameToReference() - let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) let frameInHostRaw = hostView.convert(frameInWindow, from: nil) let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) let hostBounds = hostView.bounds @@ -883,8 +1597,38 @@ final class WindowBrowserPortal: NSObject { "anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" ) #endif + if entry.visibleInUI { + let shouldPreserveVisibleOnTransient = !containerView.isHidden && + scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: "hostBoundsNotReady" + ) + if shouldPreserveVisibleOnTransient { +#if DEBUG + dlog( + "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + + "reason=hostBoundsNotReady frame=\(browserPortalDebugFrame(containerView.frame))" + ) +#endif + return + } + } else { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } + containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true - scheduleDeferredFullSynchronizeAll() + if entry.visibleInUI { + _ = scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: "hostBoundsNotReady" + ) + } else { + scheduleDeferredFullSynchronizeAll() + } return } let oldFrame = containerView.frame @@ -908,6 +1652,28 @@ final class WindowBrowserPortal: NSObject { tinyFrame || !hasFiniteFrame || outsideHostBounds + let transientRecoveryReason: String? = { + guard entry.visibleInUI else { return nil } + if anchorHidden { return "anchorHidden" } + if !hasFiniteFrame { return "nonFiniteFrame" } + if outsideHostBounds { return "outsideHostBounds" } + if tinyFrame { return "tinyFrame" } + return nil + }() + let didScheduleTransientRecovery: Bool = { + guard let transientRecoveryReason else { return false } + return scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: transientRecoveryReason + ) + }() + let shouldPreserveVisibleOnTransientGeometry = + didScheduleTransientRecovery && + shouldHide && + entry.visibleInUI && + !containerView.isHidden #if DEBUG let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) if frameWasClamped { @@ -934,13 +1700,20 @@ final class WindowBrowserPortal: NSObject { ) } #endif + if shouldPreserveVisibleOnTransientGeometry { +#if DEBUG + dlog( + "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + + "reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame))" + ) +#endif + } if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) containerView.frame = targetFrame CATransaction.commit() - webView.needsLayout = true - webView.layoutSubtreeIfNeeded() + refreshReasons.append("frame") } let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) @@ -957,6 +1730,7 @@ final class WindowBrowserPortal: NSObject { "target=\(browserPortalDebugFrame(expectedContainerBounds))" ) #endif + refreshReasons.append("bounds") } let containerBounds = containerView.bounds @@ -985,20 +1759,51 @@ final class WindowBrowserPortal: NSObject { "source=\(source)" ) #endif + refreshReasons.append("webFrame") } - if containerView.isHidden != shouldHide { + let revealedForDisplay = !shouldHide && containerView.isHidden + if shouldHide, !containerView.isHidden, !shouldPreserveVisibleOnTransientGeometry { #if DEBUG dlog( "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + "web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " + + "host=\(browserPortalDebugFrame(hostBounds))" + ) +#endif + containerView.isHidden = true + } else if !shouldHide, containerView.isHidden { +#if DEBUG + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=0 " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + "outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " + "host=\(browserPortalDebugFrame(hostBounds))" ) #endif - containerView.isHidden = shouldHide + containerView.isHidden = false + } + containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone) + if revealedForDisplay { + refreshReasons.append("reveal") + } + if forcePresentationRefresh { + refreshReasons.append("anchor") + } + if transientRecoveryReason == nil { + resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) + } + if !shouldHide, !refreshReasons.isEmpty { + refreshHostedWebViewPresentation( + webView, + in: containerView, + reason: "\(source):" + refreshReasons.joined(separator: ",") + ) } #if DEBUG dlog( @@ -1026,16 +1831,18 @@ final class WindowBrowserPortal: NSObject { let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in guard entry.webView != nil else { return webViewId } guard let container = entry.containerView else { return webViewId } - guard let anchor = entry.anchorView else { return webViewId } + guard let anchor = entry.anchorView else { + return entry.visibleInUI ? nil : webViewId + } if container.superview == nil || !container.isDescendant(of: hostView) { return webViewId } - if anchor.window !== currentWindow || anchor.superview == nil { - return webViewId - } - if let reference = installedReferenceView, - !anchor.isDescendant(of: reference) { - return webViewId + let anchorInvalidForCurrentHost = + anchor.window !== currentWindow || + anchor.superview == nil || + (installedReferenceView.map { !anchor.isDescendant(of: $0) } ?? false) + if anchorInvalidForCurrentHost { + return entry.visibleInUI ? nil : webViewId } return nil } @@ -1190,6 +1997,20 @@ enum BrowserWindowPortalRegistry { portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) } + static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateDropZoneOverlay(forWebViewId: webViewId, zone: zone) + } + + static func updatePaneDropContext(for webView: WKWebView, context: BrowserPaneDropContext?) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updatePaneDropContext(forWebViewId: webViewId, context: context) + } + 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 9a50947e..b974081b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -631,7 +631,12 @@ final class FileDropOverlayView: NSView { } /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. - private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + if let window, + let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { + return portalWebView + } + guard let window, let contentView = window.contentView else { return nil } isHidden = true defer { isHidden = false } @@ -5707,7 +5712,7 @@ struct VerticalTabsSidebar: View { @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? - @StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor() + @StateObject private var modifierKeyMonitor = SidebarShortcutHintModifierMonitor() @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @State private var draggedTabId: UUID? @@ -5735,7 +5740,7 @@ struct VerticalTabsSidebar: View { selection: $selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsCommandShortcutHints: commandKeyMonitor.isCommandPressed, + showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator @@ -5791,12 +5796,12 @@ struct VerticalTabsSidebar: View { .background(SidebarBackdrop().ignoresSafeArea()) .background( WindowAccessor { window in - commandKeyMonitor.setHostWindow(window) + modifierKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) .onAppear { - commandKeyMonitor.start() + modifierKeyMonitor.start() draggedTabId = nil dropIndicator = nil SidebarDragLifecycleNotification.postStateDidChange( @@ -5805,7 +5810,7 @@ struct VerticalTabsSidebar: View { ) } .onDisappear { - commandKeyMonitor.stop() + modifierKeyMonitor.stop() dragAutoScrollController.stop() dragFailsafeMonitor.stop() draggedTabId = nil @@ -5849,15 +5854,18 @@ struct VerticalTabsSidebar: View { } } -enum SidebarCommandHintPolicy { +enum ShortcutHintModifierPolicy { static let intentionalHoldDelay: TimeInterval = 0.30 static func shouldShowHints( for modifierFlags: NSEvent.ModifierFlags, defaults: UserDefaults = .standard ) -> Bool { - ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) && - modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] + let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) + guard normalized == [.command] else { + return false + } + return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) } static func isCurrentWindow( @@ -5922,6 +5930,11 @@ enum ShortcutHintDebugSettings { } return defaults.bool(forKey: showHintsOnCommandHoldKey) } + + static func resetVisibilityDefaults(defaults: UserDefaults = .standard) { + defaults.set(defaultAlwaysShowHints, forKey: alwaysShowHintsKey) + defaults.set(defaultShowHintsOnCommandHold, forKey: showHintsOnCommandHoldKey) + } } enum SidebarDragLifecycleNotification { @@ -6139,8 +6152,8 @@ private struct SidebarExternalDropDelegate: DropDelegate { } @MainActor -private final class SidebarCommandKeyMonitor: ObservableObject { - @Published private(set) var isCommandPressed = false +private final class SidebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false private weak var hostWindow: NSWindow? private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? @@ -6234,7 +6247,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, @@ -6243,7 +6256,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: modifierFlags, hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, @@ -6258,31 +6271,31 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } private func queueHintShow() { - guard !isCommandPressed else { return } + guard !isModifierPressed else { return } guard pendingShowWorkItem == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: NSEvent.modifierFlags, hostWindowNumber: self.hostWindow?.windowNumber, hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, eventWindowNumber: nil, keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { return } - self.isCommandPressed = true + self.isModifierPressed = true } pendingShowWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem) } private func cancelPendingHintShow(resetVisible: Bool) { pendingShowWorkItem?.cancel() pendingShowWorkItem = nil if resetVisible { - isCommandPressed = false + isModifierPressed = false } } @@ -6520,7 +6533,7 @@ private struct TabItemView: View { @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? - let showsCommandShortcutHints: Bool + let showsModifierShortcutHints: Bool let dragAutoScrollController: SidebarDragAutoScrollController @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? @@ -6623,7 +6636,7 @@ private struct TabItemView: View { } private var showCloseButton: Bool { - isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints) + isHovering && tabManager.tabs.count > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints) } private var workspaceShortcutLabel: String? { @@ -6632,7 +6645,7 @@ private struct TabItemView: View { } private var showsWorkspaceShortcutHint: Bool { - (showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil + (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil } private var workspaceHintSlotWidth: CGFloat { @@ -6748,7 +6761,7 @@ private struct TabItemView: View { .transition(.opacity) } } - .animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints) + .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index 635aecdb..9a022e5f 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -14,11 +14,21 @@ struct BrowserSearchOverlay: View { private let padding: CGFloat = 8 + private func requestSearchFieldFocus(maxAttempts: Int = 3) { + guard maxAttempts > 0 else { return } + isSearchFieldFocused = true + guard maxAttempts > 1 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + requestSearchFieldFocus(maxAttempts: maxAttempts - 1) + } + } + var body: some View { GeometryReader { geo in HStack(spacing: 4) { TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) + .accessibilityIdentifier("BrowserFindSearchTextField") .frame(width: 180) .padding(.leading, 8) .padding(.trailing, 50) @@ -95,13 +105,13 @@ struct BrowserSearchOverlay: View { #if DEBUG dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))") #endif - isSearchFieldFocused = true + requestSearchFieldFocus() } .onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in guard let notifiedPanelId = notification.object as? UUID, notifiedPanelId == panelId else { return } DispatchQueue.main.async { - isSearchFieldFocused = true + requestSearchFieldFocus() } } .background( diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index 17c795e6..0efc3d50 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -55,6 +55,7 @@ struct SurfaceSearchOverlay: View { onNavigateSearch(action) } ) + .accessibilityIdentifier("TerminalFindSearchTextField") .frame(width: 180) .padding(.leading, 8) .padding(.trailing, 50) @@ -303,6 +304,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable { let field = SearchNativeTextField(frame: .zero) field.font = .systemFont(ofSize: NSFont.systemFontSize) field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search") + field.setAccessibilityIdentifier("TerminalFindSearchTextField") field.delegate = context.coordinator field.stringValue = text context.coordinator.parentField = field diff --git a/Sources/KeyboardLayout.swift b/Sources/KeyboardLayout.swift index 392d0723..f7b7110a 100644 --- a/Sources/KeyboardLayout.swift +++ b/Sources/KeyboardLayout.swift @@ -1,3 +1,4 @@ +import AppKit import Carbon class KeyboardLayout { @@ -12,8 +13,12 @@ class KeyboardLayout { return nil } - /// Translate a physical keyCode to the unmodified character under the current keyboard layout. - static func character(forKeyCode keyCode: UInt16) -> String? { + /// Translate a physical keyCode to the character AppKit would use for shortcut matching, + /// preserving command-aware layouts such as "Dvorak - QWERTY Command". + static func character( + forKeyCode keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags = [] + ) -> String? { guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil @@ -31,7 +36,7 @@ class KeyboardLayout { keyboardLayout, keyCode, UInt16(kUCKeyActionDisplay), - 0, + translationModifierKeyState(for: modifierFlags), UInt32(LMGetKbdType()), UInt32(kUCKeyTranslateNoDeadKeysBit), &deadKeyState, @@ -43,4 +48,20 @@ class KeyboardLayout { guard status == noErr, length > 0 else { return nil } return String(utf16CodeUnits: chars, count: length).lowercased() } + + private static func translationModifierKeyState(for modifierFlags: NSEvent.ModifierFlags) -> UInt32 { + let normalized = modifierFlags + .intersection(.deviceIndependentFlagsMask) + .intersection([.shift, .command]) + + var carbonModifiers: Int = 0 + if normalized.contains(.shift) { + carbonModifiers |= shiftKey + } + if normalized.contains(.command) { + carbonModifiers |= cmdKey + } + + return UInt32((carbonModifiers >> 8) & 0xFF) + } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 885dd16d..89858475 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1595,10 +1595,8 @@ final class BrowserPanel: Panel, ObservableObject { Task { @MainActor [weak self] in self?.refreshFavicon(from: webView) self?.applyBrowserThemeModeIfNeeded() - // Clear find-in-page on navigation so stale highlights don't persist. - if self?.searchState != nil { - self?.searchState = nil - } + // Keep find-in-page open through load completion and refresh matches for the new DOM. + self?.restoreFindStateAfterNavigation(replaySearch: true) } } navDelegate.didFailNavigation = { [weak self] _, failedURL in @@ -1609,10 +1607,8 @@ final class BrowserPanel: Panel, ObservableObject { self.pageTitle = failedURL.isEmpty ? "" : failedURL self.faviconPNGData = nil self.lastFaviconURLString = nil - // Clear find-in-page so stale highlights don't persist. - if self.searchState != nil { - self.searchState = nil - } + // Keep find-in-page open and clear stale counters on failed loads. + self.restoreFindStateAfterNavigation(replaySearch: false) } } navDelegate.openInNewTab = { [weak self] url in @@ -2645,6 +2641,18 @@ extension BrowserPanel { if searchState == nil { searchState = BrowserSearchState() } + postBrowserSearchFocusNotification() + // Focus notification can race with portal overlay mount. Re-post on the + // next runloop and shortly after so the find field can claim first responder. + DispatchQueue.main.async { [weak self] in + self?.postBrowserSearchFocusNotification() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.postBrowserSearchFocusNotification() + } + } + + private func postBrowserSearchFocusNotification() { NotificationCenter.default.post(name: .browserSearchFocus, object: id) } @@ -2668,6 +2676,16 @@ extension BrowserPanel { searchState = nil } + private func restoreFindStateAfterNavigation(replaySearch: Bool) { + guard let state = searchState else { return } + state.total = nil + state.selected = nil + if replaySearch, !state.needle.isEmpty { + executeFindSearch(state.needle) + } + postBrowserSearchFocusNotification() + } + private func executeFindSearch(_ needle: String) { guard !needle.isEmpty else { executeFindClear() @@ -2743,6 +2761,9 @@ extension BrowserPanel { if suppressWebViewFocusForAddressBar { return true } + if searchState != nil { + return true + } if let until = suppressWebViewFocusUntil { return Date() < until } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c965af1b..07295066 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -211,6 +211,7 @@ struct BrowserPanelView: View { let portalPriority: Int let onRequestPanelFocus: () -> Void @Environment(\.colorScheme) private var colorScheme + @Environment(\.paneDropZone) private var paneDropZone @State private var omnibarState = OmnibarState() @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @@ -317,7 +318,10 @@ struct BrowserPanelView: View { .allowsHitTesting(false) } .overlay { - if let searchState = panel.searchState { + // 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. + if !panel.shouldRenderWebView, let searchState = panel.searchState { BrowserSearchOverlay( panelId: panel.id, searchState: searchState, @@ -734,10 +738,12 @@ struct BrowserPanelView: View { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, + browserSearchState: panel.searchState, shouldAttachWebView: isVisibleInUI, shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, - portalZPriority: portalPriority + portalZPriority: portalPriority, + paneDropZone: paneDropZone ) // Keep the host stable for normal pane churn, but force a remount when // BrowserPanel replaces its underlying WKWebView after process termination. @@ -3032,10 +3038,12 @@ 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? final class Coordinator { weak var panel: BrowserPanel? @@ -3044,6 +3052,7 @@ struct WebViewRepresentable: NSViewRepresentable { var desiredPortalVisibleInUI: Bool = true var desiredPortalZPriority: Int = 0 var lastPortalHostId: ObjectIdentifier? + var searchOverlayHostingView: NSHostingView? } private final class HostContainerView: NSView { @@ -3196,6 +3205,67 @@ 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 + } + + 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) { guard let host = nsView as? HostContainerView else { return } @@ -3206,6 +3276,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.desiredPortalZPriority = portalZPriority coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration + let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in guard let host, let webView, let coordinator else { return } @@ -3217,7 +3288,15 @@ struct WebViewRepresentable: NSViewRepresentable { visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) + BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext) 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 } @@ -3249,6 +3328,11 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.lastPortalHostId = hostId } BrowserWindowPortalRegistry.synchronizeForAnchor(host) + Self.updateSearchOverlay( + panel: panel, + coordinator: coordinator, + containerView: webView.superview + ) } 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 @@ -3258,8 +3342,18 @@ struct WebViewRepresentable: NSViewRepresentable { visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) + Self.removeSearchOverlay(from: coordinator) } + BrowserWindowPortalRegistry.updateDropZoneOverlay( + for: webView, + zone: shouldAttachWebView ? paneDropZone : nil + ) + BrowserWindowPortalRegistry.updatePaneDropContext( + for: webView, + context: paneDropContext + ) + panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG @@ -3277,6 +3371,7 @@ 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 } @@ -3348,6 +3443,7 @@ 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 @@ -3372,7 +3468,24 @@ struct WebViewRepresentable: NSViewRepresentable { window.makeFirstResponder(nil) } } - BrowserWindowPortalRegistry.detach(webView: webView) + + // SwiftUI can transiently dismantle/rebuild the browser host view during split + // 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.updatePaneDropContext(for: webView, context: nil) coordinator.lastPortalHostId = nil } + + private func currentPaneDropContext() -> BrowserPaneDropContext? { + guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }), + let paneId = workspace.paneId(forPanelId: panel.id) else { + return nil + } + return BrowserPaneDropContext( + workspaceId: panel.workspaceId, + panelId: panel.id, + paneId: paneId + ) + } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index a8a9e144..723dedb9 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1113,6 +1113,11 @@ final class CmuxWebView: WKWebView { NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"), ] + static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) + } + override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) { let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) } if !filtered.isEmpty { @@ -1120,6 +1125,21 @@ final class CmuxWebView: WKWebView { } } + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] } + return super.draggingEntered(sender) + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] } + return super.draggingUpdated(sender) + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false } + return super.performDragOperation(sender) + } + override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) lastContextMenuPoint = convert(event.locationInWindow, from: nil) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cd5ed2da..07259451 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -600,7 +600,7 @@ class TabManager: ObservableObject { self.focusSelectedTabPanel(previousTabId: previousTabId) self.updateWindowTitleForSelectedTab() if let selectedTabId = self.selectedTabId { - self.markFocusedPanelReadIfActive(tabId: selectedTabId) + self.flashFocusedPanelIfUnreadAndActive(tabId: selectedTabId) } #if DEBUG let dtMs = self.debugWorkspaceSwitchStartTime > 0 @@ -672,7 +672,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 } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId) + flashPanelIfUnreadAndActive(tabId: tabId, panelId: surfaceId) } }) @@ -1618,16 +1618,16 @@ class TabManager: ObservableObject { selectedTabId != pendingTabId } - private func markFocusedPanelReadIfActive(tabId: UUID) { + private func flashFocusedPanelIfUnreadAndActive(tabId: UUID) { let shouldSuppressFlash = suppressFocusFlash suppressFocusFlash = false guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) + flashPanelIfUnreadAndActive(tabId: tabId, panelId: panelId) } - private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { + private func flashPanelIfUnreadAndActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } guard AppFocusState.isAppActive() else { return } @@ -1636,7 +1636,6 @@ 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) { @@ -1762,7 +1761,6 @@ 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) } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index dd9d9a1a..5774a76f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13,6 +13,9 @@ class TerminalController { let acceptLoopAlive: Bool let socketPathMatches: Bool let socketPathExists: Bool + let socketProbePerformed: Bool + let socketConnectable: Bool? + let socketConnectErrno: Int32? var failureSignals: [String] { var signals: [String] = [] @@ -20,6 +23,9 @@ class TerminalController { if !acceptLoopAlive { signals.append("accept_loop_dead") } if !socketPathMatches { signals.append("socket_path_mismatch") } if !socketPathExists { signals.append("socket_missing") } + if socketProbePerformed && isRunning && acceptLoopAlive && socketPathMatches && socketPathExists && socketConnectable == false { + signals.append("socket_unreachable") + } return signals } @@ -51,6 +57,14 @@ class TerminalController { private nonisolated static let acceptFailureMaxBackoffMs = 5_000 private nonisolated static let acceptFailureMinimumRearmDelayMs = 100 private nonisolated static let acceptFailureRearmThreshold = 50 + private nonisolated static let socketProbePollTimeoutMs: Int32 = 100 + private nonisolated static let socketProbePollAttempts = 3 + private nonisolated static let socketProbePollRetryBackoffUs: useconds_t = 50_000 + private nonisolated static let unixSocketPathMaxLength: Int = { + var addr = sockaddr_un() + // Reserve one byte for the null terminator. + return MemoryLayout.size(ofValue: addr.sun_path) - 1 + }() private struct ListenerStateSnapshot { let socketPath: String @@ -508,6 +522,99 @@ class TerminalController { return !isRunning && activeGeneration == 0 } + private nonisolated static func unixSocketAddress(path: String) -> sockaddr_un? { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLength = unixSocketPathMaxLength + 1 + var didFit = false + path.withCString { source in + let sourceLength = strlen(source) + guard sourceLength < maxLength else { return } + + _ = withUnsafeMutableBytes(of: &addr.sun_path) { buffer in + buffer.initializeMemory(as: UInt8.self, repeating: 0) + } + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let destination = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(destination, source, maxLength - 1) + } + didFit = true + } + return didFit ? addr : nil + } + + private nonisolated static func bindUnixSocket(_ socket: Int32, path: String) -> Int32? { + guard var addr = unixSocketAddress(path: path) else { return nil } + return withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(socket, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + } + + private nonisolated static func probeSocketConnectability(path: String) -> (isConnectable: Bool?, errnoCode: Int32?) { + let probeSocket = socket(AF_UNIX, SOCK_STREAM, 0) + guard probeSocket >= 0 else { + return (false, errno) + } + defer { close(probeSocket) } + + let existingFlags = fcntl(probeSocket, F_GETFL, 0) + if existingFlags >= 0 { + _ = fcntl(probeSocket, F_SETFL, existingFlags | O_NONBLOCK) + } + + guard var addr = unixSocketAddress(path: path) else { + return (false, ENAMETOOLONG) + } + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(probeSocket, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + if connectResult == 0 { + return (true, nil) + } + let connectErrno = errno + if connectErrno == EINPROGRESS { + var pollDescriptor = pollfd(fd: probeSocket, events: Int16(POLLOUT), revents: 0) + for attempt in 0.. 0 { + var socketError: Int32 = 0 + var socketErrorLength = socklen_t(MemoryLayout.size) + let status = getsockopt( + probeSocket, + SOL_SOCKET, + SO_ERROR, + &socketError, + &socketErrorLength + ) + if status == 0 && socketError == 0 { + return (true, nil) + } + if status == 0 { + return (false, socketError) + } + return (false, errno) + } + + let pollErrno = errno + if pollResult == 0 || pollErrno == EINTR { + if attempt + 1 < Self.socketProbePollAttempts { + usleep(Self.socketProbePollRetryBackoffUs) + continue + } + return (false, pollResult == 0 ? ETIMEDOUT : pollErrno) + } + return (false, pollErrno) + } + } + return (false, connectErrno) + } + func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) { self.tabManager = tabManager self.accessMode = accessMode @@ -556,19 +663,18 @@ class TerminalController { } // Bind to path - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strcpy(pathBuf, ptr) - } - } - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - bind(newServerSocket, sockaddrPtr, socklen_t(MemoryLayout.size)) - } + guard let bindResult = Self.bindUnixSocket(newServerSocket, path: socketPath) else { + close(newServerSocket) + reportSocketListenerFailure( + message: "socket.listener.start.failed", + stage: "bind_path_too_long", + errnoCode: ENAMETOOLONG, + extra: [ + "pathLength": socketPath.utf8.count, + "maxPathLength": Self.unixSocketPathMaxLength + ] + ) + return } guard bindResult >= 0 else { @@ -653,12 +759,19 @@ class TerminalController { var st = stat() let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK + let shouldProbeConnection = snapshot.isRunning && snapshot.acceptLoopAlive && pathMatches && exists + let connectability = shouldProbeConnection + ? Self.probeSocketConnectability(path: expectedSocketPath) + : (isConnectable: nil, errnoCode: nil) return SocketListenerHealth( isRunning: snapshot.isRunning, acceptLoopAlive: snapshot.acceptLoopAlive, socketPathMatches: pathMatches, - socketPathExists: exists + socketPathExists: exists, + socketProbePerformed: shouldProbeConnection, + socketConnectable: connectability.isConnectable, + socketConnectErrno: connectability.errnoCode ) } @@ -5285,41 +5398,70 @@ class TerminalController { _ webView: WKWebView, script: String, timeout: TimeInterval = 5.0, - preferAsync: Bool = false + preferAsync: Bool = false, + contentWorld: WKContentWorld ) -> V2JavaScriptResult { + let timeoutSeconds = max(0.01, timeout) + let resultLock = NSLock() + let completionSignal = DispatchSemaphore(value: 0) var done = false var resultValue: Any? var resultError: String? - if preferAsync, #available(macOS 11.0, *) { - webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in - switch result { - case .success(let value): - resultValue = value - case .failure(let error): - resultError = error.localizedDescription - } + let finish: (_ value: Any?, _ error: String?) -> Void = { value, error in + resultLock.lock() + if !done { done = true + resultValue = value + resultError = error + completionSignal.signal() + } + resultLock.unlock() + } + + let evaluator = { + if preferAsync, #available(macOS 11.0, *) { + webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in + switch result { + case .success(let value): + finish(value, nil) + case .failure(let error): + finish(nil, error.localizedDescription) + } + } + } else { + webView.evaluateJavaScript(script) { value, error in + if let error { + finish(nil, error.localizedDescription) + } else { + finish(value, nil) + } + } + } + } + + if Thread.isMainThread { + evaluator() + let deadline = Date().addingTimeInterval(timeoutSeconds) + while true { + resultLock.lock() + let isDone = done + resultLock.unlock() + if isDone { + break + } + if Date() >= deadline { + return .failure("Timed out waiting for JavaScript result") + } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } } else { - webView.evaluateJavaScript(script) { value, error in - if let error { - resultError = error.localizedDescription - } else { - resultValue = value - } - done = true + DispatchQueue.main.async(execute: evaluator) + if completionSignal.wait(timeout: .now() + timeoutSeconds) == .timedOut { + return .failure("Timed out waiting for JavaScript result") } } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - - if !done { - return .failure("Timed out waiting for JavaScript result") - } if let resultError { return .failure(resultError) } @@ -5369,7 +5511,8 @@ class TerminalController { _ webView: WKWebView, surfaceId: UUID, script: String, - timeout: TimeInterval = 5.0 + timeout: TimeInterval = 5.0, + useEval: Bool = true ) -> V2JavaScriptResult { let scriptLiteral = v2JSONLiteral(script) let framePrelude: String @@ -5388,6 +5531,13 @@ class TerminalController { framePrelude = "const __cmuxDoc = document;" } + let executionBlock: String + if useEval { + executionBlock = "const __r = eval(\(scriptLiteral));" + } else { + executionBlock = "const __r = \(script);" + } + let asyncFunctionBody = """ \(framePrelude) @@ -5400,7 +5550,7 @@ class TerminalController { const __cmuxEvalInFrame = async function() { const document = __cmuxDoc; - const __r = eval(\(scriptLiteral)); + \(executionBlock) const __value = await __cmuxMaybeAwait(__r); return { __cmux_t: (typeof __value === 'undefined') ? 'undefined' : 'value', @@ -5411,16 +5561,40 @@ class TerminalController { return await __cmuxEvalInFrame(); """ - let rawResult: V2JavaScriptResult + var rawResult: V2JavaScriptResult if #available(macOS 11.0, *) { - rawResult = v2RunJavaScript(webView, script: asyncFunctionBody, timeout: timeout, preferAsync: true) + rawResult = v2RunJavaScript( + webView, + script: asyncFunctionBody, + timeout: timeout, + preferAsync: true, + contentWorld: .page + ) } else { let evaluateFallback = """ (async () => { \(asyncFunctionBody) })() """ - rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout) + rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout, contentWorld: .page) + } + + if !useEval, case .failure(let pageMessage) = rawResult, #available(macOS 11.0, *) { + let isolatedResult = v2RunJavaScript( + webView, + script: asyncFunctionBody, + timeout: timeout, + preferAsync: true, + contentWorld: .defaultClient + ) + switch isolatedResult { + case .success: + rawResult = isolatedResult + case .failure(let isolatedMessage): + if isolatedMessage != pageMessage { + rawResult = .failure("\(pageMessage) (isolated-world retry: \(isolatedMessage))") + } + } } switch rawResult { @@ -5521,38 +5695,41 @@ class TerminalController { } } - private func v2BrowserWaitForCondition( - _ conditionScript: String, - webView: WKWebView, - surfaceId: UUID? = nil, - timeout: TimeInterval = 5.0, - pollInterval: TimeInterval = 0.05 - ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let wrapped = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()" - let jsResult: V2JavaScriptResult - if let surfaceId { - jsResult = v2RunBrowserJavaScript(webView, surfaceId: surfaceId, script: wrapped, timeout: max(0.5, pollInterval + 0.25)) - } else { - jsResult = v2RunJavaScript(webView, script: wrapped, timeout: max(0.5, pollInterval + 0.25)) - } - if case let .success(value) = jsResult, - let ok = value as? Bool, - ok { - return true - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(pollInterval)) - } - return false - } - private func v2PNGData(from image: NSImage) -> Data? { guard let tiff = image.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return nil } return rep.representation(using: .png, properties: [:]) } + private func bestEffortPruneTemporaryFiles( + in directoryURL: URL, + keepingMostRecent maxCount: Int = 50, + maxAge: TimeInterval = 24 * 60 * 60 + ) { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + let now = Date() + let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]), + values.isRegularFile == true else { + return nil + } + return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast) + }.sorted { $0.date > $1.date } + + for (index, entry) in datedEntries.enumerated() { + if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge { + try? FileManager.default.removeItem(at: entry.url) + } + } + } + // MARK: - Markdown private func v2MarkdownOpen(params: [String: Any]) -> V2CallResult { @@ -5973,7 +6150,7 @@ class TerminalController { let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3) for attempt in 1...retryAttempts { - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, useEval: false) { case .failure(let message): return .err(code: "js_error", message: message, data: ["action": actionName, "selector": selector]) case .success(let value): @@ -6231,7 +6408,7 @@ class TerminalController { })() """ - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) { + switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0, useEval: false) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -6328,42 +6505,120 @@ class TerminalController { private func v2BrowserWait(params: [String: Any]) -> V2CallResult { let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000) let timeout = Double(timeoutMs) / 1000.0 + let selectorRaw = v2BrowserSelector(params) - return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - let conditionScript: String = { - if let selector = v2BrowserSelector(params) { - let literal = v2JSONLiteral(selector) - return "document.querySelector(\(literal)) !== null" + let conditionScriptBase: String = { + if let urlContains = v2String(params, "url_contains") { + let literal = v2JSONLiteral(urlContains) + return "String(location.href || '').includes(\(literal))" + } + if let textContains = v2String(params, "text_contains") { + let literal = v2JSONLiteral(textContains) + return "(document.body && String(document.body.innerText || '').includes(\(literal)))" + } + if let loadState = v2String(params, "load_state") { + let normalizedLoadState = loadState.lowercased() + if normalizedLoadState == "interactive" { + return """ + (() => { + const __state = String(document.readyState || '').toLowerCase(); + return __state === 'interactive' || __state === 'complete'; + })() + """ } - if let urlContains = v2String(params, "url_contains") { - let literal = v2JSONLiteral(urlContains) - return "String(location.href || '').includes(\(literal))" - } - if let textContains = v2String(params, "text_contains") { - let literal = v2JSONLiteral(textContains) - return "(document.body && String(document.body.innerText || '').includes(\(literal)))" - } - if let loadState = v2String(params, "load_state") { - let literal = v2JSONLiteral(loadState.lowercased()) - return "String(document.readyState || '').toLowerCase() === \(literal)" - } - if let fn = v2String(params, "function") { - return "(() => { return !!(\(fn)); })()" - } - return "document.readyState === 'complete'" - }() + let literal = v2JSONLiteral(normalizedLoadState) + return "String(document.readyState || '').toLowerCase() === \(literal)" + } + if let fn = v2String(params, "function") { + return "(() => { return !!(\(fn)); })()" + } + return "document.readyState === 'complete'" + }() - let ok = v2BrowserWaitForCondition(conditionScript, webView: browserPanel.webView, surfaceId: surfaceId, timeout: timeout) - if !ok { + var setupResult: V2CallResult? + var workspaceId: UUID? + var surfaceIdOut: UUID? + var webView: WKWebView? + + v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + setupResult = .err(code: "unavailable", message: "TabManager not available", data: nil) + return + } + guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + setupResult = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let surfaceId = self.v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let surfaceId else { + setupResult = .err(code: "not_found", message: "No focused browser surface", data: nil) + return + } + guard let browserPanel = ws.browserPanel(for: surfaceId) else { + setupResult = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString]) + return + } + workspaceId = ws.id + surfaceIdOut = surfaceId + webView = browserPanel.webView + } + + if let setupResult { + return setupResult + } + guard let workspaceId, let surfaceIdOut, let webView else { + return .err(code: "internal_error", message: "Failed to resolve browser surface", data: nil) + } + + let conditionScript: String + if let selectorRaw { + guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceIdOut) else { + return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw]) + } + let literal = v2JSONLiteral(selector) + conditionScript = "document.querySelector(\(literal)) !== null" + } else { + conditionScript = conditionScriptBase + } + + let deadline = Date().addingTimeInterval(timeout) + let pollInterval = 0.05 + let wrappedScript = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()" + + while true { + switch v2RunBrowserJavaScript( + webView, + surfaceId: surfaceIdOut, + script: wrappedScript, + timeout: max(0.5, pollInterval + 0.25), + useEval: false + ) { + case .success(let value): + if let b = value as? Bool, b { + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceIdOut.uuidString, + "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut), + "waited": true + ]) + } + case .failure(let message): + return .err( + code: "js_error", + message: message, + data: [ + "condition": conditionScript, + "timeout_ms": timeoutMs + ] + ) + } + + if Date() >= deadline { return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) } - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "waited": true - ]) + + Thread.sleep(forTimeInterval: pollInterval) } } @@ -6708,13 +6963,31 @@ class TerminalController { return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) } - return .ok([ + var result: [String: Any] = [ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "png_base64": imageData.base64EncodedString() - ]) + ] + + // Best effort: keep screenshot data available even when temp-file writes fail. + let screenshotsDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-browser-screenshots", isDirectory: true) + if (try? FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true)) != nil { + bestEffortPruneTemporaryFiles(in: screenshotsDirectory) + let timestampMs = Int(Date().timeIntervalSince1970 * 1000) + let shortSurfaceId = String(surfaceId.uuidString.prefix(8)) + let shortRandomId = String(UUID().uuidString.prefix(8)) + let filename = "surface-\(shortSurfaceId)-\(timestampMs)-\(shortRandomId).png" + let imageURL = screenshotsDirectory.appendingPathComponent(filename, isDirectory: false) + if (try? imageData.write(to: imageURL, options: .atomic)) != nil { + result["path"] = imageURL.path + result["url"] = imageURL.absoluteString + } + } + + return .ok(result) } } @@ -7544,7 +7817,8 @@ class TerminalController { _ = v2RunJavaScript( browserPanel.webView, script: BrowserPanel.telemetryHookBootstrapScriptSource, - timeout: 5.0 + timeout: 5.0, + contentWorld: .page ) } @@ -7552,7 +7826,8 @@ class TerminalController { _ = v2RunJavaScript( browserPanel.webView, script: BrowserPanel.dialogTelemetryHookBootstrapScriptSource, - timeout: 5.0 + timeout: 5.0, + contentWorld: .page ) } @@ -7584,7 +7859,7 @@ class TerminalController { })() """ - switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) { + switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -8179,7 +8454,7 @@ class TerminalController { return { ok: true, items }; })() """ - switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) { + switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -8217,7 +8492,7 @@ class TerminalController { return { ok: true, items }; })() """ - switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) { + switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 5bb768cb..1f1dac18 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -833,16 +833,9 @@ final class TerminalNotificationStore: ObservableObject { let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId let isFocusedPanel = isActiveTab && isFocusedSurface let isAppFocused = AppFocusState.isAppFocused() - if isAppFocused && isFocusedPanel { - if !idsToClear.isEmpty { - notifications = updated - center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) - center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) - } - return - } + let suppressNativeDelivery = isAppFocused && isFocusedPanel - if WorkspaceAutoReorderSettings.isEnabled() { + if WorkspaceAutoReorderSettings.isEnabled() && !suppressNativeDelivery { AppDelegate.shared?.tabManager?.moveTabToTop(tabId) } @@ -862,7 +855,11 @@ final class TerminalNotificationStore: ObservableObject { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } - scheduleUserNotification(notification) + if suppressNativeDelivery { + Self.runNotificationCustomCommand(notification) + } else { + scheduleUserNotification(notification) + } } func markRead(id: UUID) { @@ -993,10 +990,7 @@ final class TerminalNotificationStore: ObservableObject { guard let self, authorized else { return } let content = UNMutableNotificationContent() - 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.title = Self.notificationDisplayTitle(notification) content.subtitle = notification.subtitle content.body = notification.body content.sound = NotificationSoundSettings.sound() @@ -1019,16 +1013,27 @@ final class TerminalNotificationStore: ObservableObject { if let error { NSLog("Failed to schedule notification: \(error)") } else { - NotificationSoundSettings.runCustomCommand( - title: content.title, - subtitle: content.subtitle, - body: content.body - ) + Self.runNotificationCustomCommand(notification) } } } } + 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/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 0c466aca..56a1783f 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -726,6 +726,7 @@ final class WindowTerminalPortal: NSObject { guard let window else { return false } guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window) else { return false } + let browserHost = preferredBrowserHost(in: container) if hostView.superview !== container || installedContainerView !== container || @@ -734,7 +735,11 @@ final class WindowTerminalPortal: NSObject { installConstraints.removeAll() hostView.removeFromSuperview() - container.addSubview(hostView, positioned: .above, relativeTo: reference) + if let browserHost { + container.addSubview(hostView, positioned: .below, relativeTo: browserHost) + } else { + container.addSubview(hostView, positioned: .above, relativeTo: reference) + } installConstraints = [ hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor), @@ -745,6 +750,10 @@ final class WindowTerminalPortal: NSObject { NSLayoutConstraint.activate(installConstraints) installedContainerView = container installedReferenceView = reference + } else if let browserHost { + if !Self.isView(browserHost, above: hostView, in: container) { + container.addSubview(hostView, positioned: .below, relativeTo: browserHost) + } } else if !Self.isView(hostView, above: reference, in: container) { container.addSubview(hostView, positioned: .above, relativeTo: reference) } @@ -837,6 +846,10 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } + private func preferredBrowserHost(in container: NSView) -> WindowBrowserHostView? { + container.subviews.last(where: { $0 is WindowBrowserHostView }) as? WindowBrowserHostView + } + #if DEBUG private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? { var current: NSView? = anchorView diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index c7a96e0e..12af22b6 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -237,7 +237,7 @@ struct TitlebarControlsView: View { @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @State private var shortcutRefreshTick = 0 - @StateObject private var commandKeyMonitor = TitlebarCommandKeyMonitor() + @StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor() private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 private let titlebarHintBaseYShift: CGFloat = 1 @@ -269,7 +269,7 @@ struct TitlebarControlsView: View { } private var shouldShowTitlebarShortcutHints: Bool { - alwaysShowShortcutHints || commandKeyMonitor.isCommandPressed + alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed } var body: some View { @@ -283,7 +283,7 @@ struct TitlebarControlsView: View { .padding(.trailing, titlebarHintTrailingInset) .background( WindowAccessor { window in - commandKeyMonitor.setHostWindow(window) + modifierKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) @@ -291,10 +291,10 @@ struct TitlebarControlsView: View { shortcutRefreshTick &+= 1 } .onAppear { - commandKeyMonitor.start() + modifierKeyMonitor.start() } .onDisappear { - commandKeyMonitor.stop() + modifierKeyMonitor.stop() } } @@ -503,8 +503,8 @@ struct TitlebarControlsView: View { } @MainActor -private final class TitlebarCommandKeyMonitor: ObservableObject { - @Published private(set) var isCommandPressed = false +private final class TitlebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false private weak var hostWindow: NSWindow? private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? @@ -598,7 +598,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, @@ -607,7 +607,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: modifierFlags, hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, @@ -622,31 +622,31 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } private func queueHintShow() { - guard !isCommandPressed else { return } + guard !isModifierPressed else { return } guard pendingShowWorkItem == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints( + guard ShortcutHintModifierPolicy.shouldShowHints( for: NSEvent.modifierFlags, hostWindowNumber: self.hostWindow?.windowNumber, hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, eventWindowNumber: nil, keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { return } - self.isCommandPressed = true + self.isModifierPressed = true } pendingShowWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem) } private func cancelPendingHintShow(resetVisible: Bool) { pendingShowWorkItem?.cancel() pendingShowWorkItem = nil if resetVisible { - isCommandPressed = false + isModifierPressed = false } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index cf3d064b..15d0a0c4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1968,11 +1968,21 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + // Inherit working directory: prefer the source panel's reported cwd, + // fall back to the workspace's current directory. + let splitWorkingDirectory: String? = panelDirectories[panelId] + ?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil : currentDirectory) +#if DEBUG + dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")") +#endif + // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, + workingDirectory: splitWorkingDirectory, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel @@ -3086,7 +3096,14 @@ final class Workspace: Identifiable, ObservableObject { } if let browserPanel = panels[panelId] as? BrowserPanel { - maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + // Keep browser find focus behavior aligned with terminal find behavior. + // When switching back to a pane with an already-open find bar, reassert + // focus to that field instead of leaving first responder stale. + if browserPanel.searchState != nil { + browserPanel.startFind() + } else { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + } } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ff392c1f..00cd360b 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2825,6 +2825,8 @@ struct SettingsView: View { @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) + private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @@ -4107,6 +4109,8 @@ struct SettingsView: View { notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + ShortcutHintDebugSettings.resetVisibilityDefaults() + alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 49dfa9ab..22b09b39 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -8,6 +8,39 @@ import XCTest @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { + private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private var actionsWithPersistedShortcut: Set = [] + + override func setUp() { + super.setUp() + actionsWithPersistedShortcut = Set( + KeyboardShortcutSettings.Action.allCases.filter { + UserDefaults.standard.object(forKey: $0.defaultsKey) != nil + } + ) + savedShortcutsByAction = Dictionary( + uniqueKeysWithValues: actionsWithPersistedShortcut.map { action in + (action, KeyboardShortcutSettings.shortcut(for: action)) + } + ) + KeyboardShortcutSettings.resetAll() + } + + override func tearDown() { + AppDelegate.shared?.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + AppDelegate.shared?.dismissNotificationsPopoverIfShown() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + for action in KeyboardShortcutSettings.Action.allCases { + if actionsWithPersistedShortcut.contains(action), + let savedShortcut = savedShortcutsByAction[action] { + KeyboardShortcutSettings.setShortcut(savedShortcut, for: action) + } else { + KeyboardShortcutSettings.resetShortcut(for: action) + } + } + super.tearDown() + } + func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -311,6 +344,910 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") } + func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .showNotifications) { + // Dvorak: physical ANSI "I" key can produce the character "c". + // This should behave like Cmd+C (copy), not match the Cmd+I app shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 34 // kVK_ANSI_I + ) else { + XCTFail("Failed to construct Dvorak Cmd+C event on physical ANSI I key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdPhysicalPWithDvorakCharactersDoesNotTriggerCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Cmd+L should not request command palette switcher") + switcherExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + // Dvorak: physical ANSI "P" key can produce "l". + // This should behave as Cmd+L, not as physical Cmd+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "l", + charactersIgnoringModifiers: "l", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Dvorak Cmd+L event on physical ANSI P key") + return + } + +#if DEBUG + _ = appDelegate.debugHandleCustomShortcut(event: event) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPWithCapsLockStillTriggersCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Cmd+P with Caps Lock should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .capsLock], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "p", + charactersIgnoringModifiers: "p", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P + Caps Lock event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPFallsBackToANSIKeyCodeWhenCharactersAndLayoutTranslationAreUnavailable() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { _, _ in nil } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Cmd+P with unavailable characters should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event)) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPDoesNotFallbackToANSIKeyCodeWhenLayoutTranslationProvidesDifferentLetter() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { _, _ in "b" } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Non-P layout translation should not request command palette switcher") + switcherExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + _ = appDelegate.handleBrowserSurfaceKeyEquivalent(event) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdPFallsBackToCommandAwareLayoutTranslationWhenCharactersAreUnavailable() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.shortcutLayoutCharacterProvider = { keyCode, modifierFlags in + guard keyCode == 35 else { return nil } // kVK_ANSI_P + return modifierFlags.contains(.command) ? "p" : "r" + } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + let switcherExpectation = expectation(description: "Command-aware layout translation should request command palette switcher") + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Cmd+P event with unavailable characters") + return + } + + XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event)) + wait(for: [switcherExpectation], timeout: 0.15) + } + + func testCmdShiftPhysicalPWithDvorakCharactersDoesNotTriggerCommandPalette() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let paletteExpectation = expectation(description: "Cmd+Shift+L should not request command palette") + paletteExpectation.isInverted = true + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteRequested, + object: nil, + queue: nil + ) { _ in + paletteExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + // Dvorak: physical ANSI "P" key can produce "l". + // This should behave as Cmd+Shift+L, not as physical Cmd+Shift+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "l", + charactersIgnoringModifiers: "l", + isARepeat: false, + keyCode: 35 // kVK_ANSI_P + ) else { + XCTFail("Failed to construct Dvorak Cmd+Shift+L event on physical ANSI P key") + return + } + +#if DEBUG + _ = appDelegate.debugHandleCustomShortcut(event: event) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [paletteExpectation], timeout: 0.15) + } + + func testCmdOptionPhysicalTWithDvorakCharactersDoesNotTriggerCloseOtherTabsShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + // Dvorak: physical ANSI "T" key can produce "y". + // This should not match the Cmd+Option+T app shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .option], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "y", + charactersIgnoringModifiers: "y", + isARepeat: false, + keyCode: 17 // kVK_ANSI_T + ) else { + XCTFail("Failed to construct Dvorak Cmd+Option+Y event on physical ANSI T key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + let panelCountBefore = workspace.panels.count + + // Dvorak: physical ANSI "W" key can produce ",". + // This should not match the Cmd+W close-panel shortcut. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: ",", + charactersIgnoringModifiers: ",", + isARepeat: false, + keyCode: 13 // kVK_ANSI_W + ) else { + XCTFail("Failed to construct Dvorak Cmd+, event on physical ANSI W key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(workspace.panels.count, panelCountBefore) + } + + func testCmdIStillTriggersShowNotificationsShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .showNotifications) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "i", + charactersIgnoringModifiers: "i", + isARepeat: false, + keyCode: 34 // kVK_ANSI_I + ) else { + XCTFail("Failed to construct Cmd+I event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdUnshiftedSymbolDoesNotMatchDigitShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: false, option: false, control: false) + ) { + // Some non-US layouts can produce "*" without Shift. + // This must not be coerced into "8" for a Cmd+8 shortcut match. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct Cmd+* event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdDigitShortcutFallsBackByKeyCodeOnSymbolFirstLayouts() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) + ) { + // Symbol-first layouts (for example AZERTY) can report "&" for the ANSI 1 key. + // Cmd+1 shortcuts should still match via keyCode fallback in this case. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "&", + charactersIgnoringModifiers: "&", + isARepeat: false, + keyCode: 18 // kVK_ANSI_1 + ) else { + XCTFail("Failed to construct Cmd+& event on ANSI 1 key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftNonDigitKeySymbolDoesNotMatchShiftedDigitShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false) + ) { + // Avoid unrelated default Cmd+Shift+] handling for this assertion. + withTemporaryShortcut( + action: .nextSurface, + shortcut: StoredShortcut(key: "x", command: true, shift: true, option: false, control: false) + ) { + // On some non-US layouts, Shift+RightBracket can produce "*". + // This must not be interpreted as Shift+8. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct Cmd+Shift+* event from non-digit key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + } + + func testCmdShiftDigitShortcutMatchesShiftedDigitKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 28 // kVK_ANSI_8 + ) else { + XCTFail("Failed to construct Cmd+Shift+8 event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftQuestionMarkMatchesSlashShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .triggerFlash, + shortcut: StoredShortcut(key: "/", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "?", + charactersIgnoringModifiers: "?", + isARepeat: false, + keyCode: 44 // kVK_ANSI_Slash + ) else { + XCTFail("Failed to construct Cmd+Shift+/ event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftISOAngleBracketDoesNotMatchCommaShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut( + action: .showNotifications, + shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) + ) { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "<", + charactersIgnoringModifiers: "<", + isARepeat: false, + keyCode: 10 // kVK_ISO_Section + ) else { + XCTFail("Failed to construct Cmd+Shift+< event from ISO key") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdShiftRightBracketCanFallbackByKeyCodeOnNonUSLayouts() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + withTemporaryShortcut(action: .nextSurface) { + // Non-US layouts can report "*" (or other symbols) for kVK_ANSI_RightBracket with Shift. + // Shortcut matching should still allow Cmd+Shift+] via keyCode fallback. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "*", + charactersIgnoringModifiers: "*", + isARepeat: false, + keyCode: 30 // kVK_ANSI_RightBracket + ) else { + XCTFail("Failed to construct non-US Cmd+Shift+] event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + } + + func testCmdPhysicalOWithDvorakCharactersTriggersRenameTabShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let renameTabExpectation = expectation(description: "Expected rename tab request for semantic Cmd+R") + var observedRenameTabWindow: NSWindow? + let renameTabToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameTabRequested, + object: nil, + queue: nil + ) { notification in + observedRenameTabWindow = notification.object as? NSWindow + renameTabExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(renameTabToken) } + + let switcherExpectation = expectation(description: "Cmd+R should not trigger command palette switcher") + switcherExpectation.isInverted = true + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { _ in + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + withTemporaryShortcut(action: .renameTab) { + // Dvorak: physical ANSI "O" key can produce "r". + // This should behave as semantic Cmd+R (rename tab), not Cmd+P. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "r", + charactersIgnoringModifiers: "r", + isARepeat: false, + keyCode: 31 // kVK_ANSI_O + ) else { + XCTFail("Failed to construct Dvorak Cmd+R event on physical ANSI O key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + wait(for: [renameTabExpectation, switcherExpectation], timeout: 1.0) + XCTAssertEqual(observedRenameTabWindow?.windowNumber, window.windowNumber) + } + + func testCmdPhysicalRWithDvorakCharactersTriggersCommandPaletteSwitcher() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let switcherExpectation = expectation(description: "Expected command palette switcher request for semantic Cmd+P") + var observedSwitcherWindow: NSWindow? + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { notification in + observedSwitcherWindow = notification.object as? NSWindow + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + let renameTabExpectation = expectation(description: "Physical R on Dvorak should not trigger rename tab") + renameTabExpectation.isInverted = true + let renameTabToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRenameTabRequested, + object: nil, + queue: nil + ) { _ in + renameTabExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(renameTabToken) } + + // Dvorak: physical ANSI "R" key can produce "p". + // This should behave as semantic Cmd+P (palette switcher), not Cmd+R. + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "p", + charactersIgnoringModifiers: "p", + isARepeat: false, + keyCode: 15 // kVK_ANSI_R + ) else { + XCTFail("Failed to construct Dvorak Cmd+P event on physical ANSI R key") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation, renameTabExpectation], timeout: 1.0) + XCTAssertEqual(observedSwitcherWindow?.windowNumber, window.windowNumber) + } + func testCmdShiftRRequestsRenameWorkspaceInCommandPalette() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -684,6 +1621,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + #if DEBUG XCTAssertTrue( appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 20.0), @@ -1210,6 +2150,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + private func withTemporaryShortcut( + action: KeyboardShortcutSettings.Action, + shortcut: StoredShortcut? = nil, + _ body: () -> Void + ) { + let hadPersistedShortcut = UserDefaults.standard.object(forKey: action.defaultsKey) != nil + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + if hadPersistedShortcut { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) + } else { + KeyboardShortcutSettings.resetShortcut(for: action) + } + } + KeyboardShortcutSettings.setShortcut(shortcut ?? action.defaultShortcut, for: action) + body() + } + private func assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest( _ openRequest: (_ appDelegate: AppDelegate, _ window: NSWindow) -> Void, file: StaticString = #filePath, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index ca826cca..9e34690f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2423,7 +2423,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) } - func testWebViewDismantleDetachesPortalHostedWebViewWhenDeveloperToolsIntentIsVisible() { + func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() { let (panel, _) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) @@ -2446,20 +2446,22 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, + browserSearchState: nil, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, - portalZPriority: 0 + portalZPriority: 0, + paneDropZone: nil ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) - XCTAssertNil(panel.webView.superview) + XCTAssertNotNil(panel.webView.superview) window.orderOut(nil) } - func testWebViewDismantleDetachesPortalHostedWebViewWhenDeveloperToolsIntentIsHidden() { + func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() { let (panel, _) = makePanelWithInspector() XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) @@ -2482,16 +2484,18 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, + browserSearchState: nil, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, - portalZPriority: 0 + portalZPriority: 0, + paneDropZone: nil ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) - XCTAssertNil(panel.webView.superview) + XCTAssertNotNil(panel.webView.superview) window.orderOut(nil) } } @@ -2754,6 +2758,52 @@ final class FullScreenShortcutTests: XCTestCase { shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: [.command, .control], chars: "", + keyCode: 3, + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testDoesNotFallbackToANSIWhenLayoutTranslationReturnsNonFCharacter() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, _ in "u" } + ) + ) + } + + func testMatchesCommandControlFWhenCommandAwareLayoutTranslationProvidesF() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "", + keyCode: 3, + layoutCharacterProvider: { _, modifierFlags in + modifierFlags.contains(.command) ? "f" : "u" + } + ) + ) + } + + func testMatchesCommandControlFWhenCharsAreControlSequence() { + XCTAssertTrue( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "\u{06}", + keyCode: 3, + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testRejectsPhysicalFWhenCharacterRepresentsDifferentLayoutKey() { + XCTAssertFalse( + shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: [.command, .control], + chars: "u", keyCode: 3 ) ) @@ -3345,16 +3395,19 @@ final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { } } -final class SidebarCommandHintPolicyTests: XCTestCase { - func testCommandHintRequiresCommandOnlyModifier() { +final class ShortcutHintModifierPolicyTests: XCTestCase { + func testShortcutHintRequiresEnabledCommandOnlyModifier() { withDefaultsSuite { defaults in defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command], defaults: defaults)) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [], defaults: defaults)) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults)) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .option], defaults: defaults)) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .control], defaults: defaults)) + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .shift], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .option], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .option], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .control], defaults: defaults)) } } @@ -3362,7 +3415,8 @@ final class SidebarCommandHintPolicyTests: XCTestCase { withDefaultsSuite { defaults in defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) } } @@ -3370,17 +3424,18 @@ final class SidebarCommandHintPolicyTests: XCTestCase { withDefaultsSuite { defaults in defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) - XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) } } - func testCommandHintUsesIntentionalHoldDelay() { - XCTAssertGreaterThanOrEqual(SidebarCommandHintPolicy.intentionalHoldDelay, 0.25) + func testShortcutHintUsesIntentionalHoldDelay() { + XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001) } func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() { XCTAssertTrue( - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: 42, @@ -3389,7 +3444,7 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) XCTAssertFalse( - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: 7, @@ -3398,7 +3453,7 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) XCTAssertFalse( - SidebarCommandHintPolicy.isCurrentWindow( + ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: false, eventWindowNumber: 42, @@ -3407,12 +3462,12 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) } - func testWindowScopedCommandHintsUseKeyWindowWhenNoEventWindowIsAvailable() { + func testWindowScopedShortcutHintsUseKeyWindowWhenNoEventWindowIsAvailable() { withDefaultsSuite { defaults in defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) XCTAssertTrue( - SidebarCommandHintPolicy.shouldShowHints( + ShortcutHintModifierPolicy.shouldShowHints( for: [.command], hostWindowNumber: 42, hostWindowIsKey: true, @@ -3423,7 +3478,7 @@ final class SidebarCommandHintPolicyTests: XCTestCase { ) XCTAssertFalse( - SidebarCommandHintPolicy.shouldShowHints( + ShortcutHintModifierPolicy.shouldShowHints( for: [.command], hostWindowNumber: 42, hostWindowIsKey: true, @@ -3432,11 +3487,33 @@ final class SidebarCommandHintPolicyTests: XCTestCase { defaults: defaults ) ) + + XCTAssertTrue( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) + + XCTAssertFalse( + ShortcutHintModifierPolicy.shouldShowHints( + for: [.control], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42, + defaults: defaults + ) + ) } } private func withDefaultsSuite(_ body: (UserDefaults) -> Void) { - let suiteName = "SidebarCommandHintPolicyTests-\(UUID().uuidString)" + let suiteName = "ShortcutHintModifierPolicyTests-\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create defaults suite") return @@ -3486,6 +3563,31 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)) } + + func testResetVisibilityDefaultsRestoresAlwaysShowAndCommandHoldFlags() { + let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create defaults suite") + return + } + + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) + defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + ShortcutHintDebugSettings.resetVisibilityDefaults(defaults: defaults) + + XCTAssertEqual( + defaults.object(forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) as? Bool, + ShortcutHintDebugSettings.defaultAlwaysShowHints + ) + XCTAssertEqual( + defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) as? Bool, + ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + ) + } } final class ShortcutHintLanePlannerTests: XCTestCase { @@ -6654,6 +6756,8 @@ final class NotificationDockBadgeTests: XCTestCase { } override func tearDown() { + AppFocusState.overrideIsFocused = nil + AppDelegate.shared = nil TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting() TerminalNotificationStore.shared.replaceNotificationsForTesting([]) super.tearDown() @@ -7157,6 +7261,155 @@ 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() @@ -7708,6 +7961,197 @@ final class WindowBrowserHostViewTests: XCTestCase { let contentPointInHost = host.convert(contentPointInWindow, from: nil) XCTAssertTrue(host.hitTest(contentPointInHost) === child) } + + func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() { + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .cursorUpdate + ) + ) + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .mouseEntered + ) + ) + } + + func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() { + XCTAssertTrue( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType], + eventType: .cursorUpdate + ) + ) + } + + func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() { + XCTAssertFalse( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: [.fileURL], + eventType: .cursorUpdate + ) + ) + } +} + +@MainActor +final class CmuxWebViewDragRoutingTests: XCTestCase { + func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { + XCTAssertTrue( + CmuxWebView.shouldRejectInternalPaneDrag([ + DragOverlayRoutingPolicy.bonsplitTabTransferType, + NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), + ]) + ) + } + + func testAllowsRegularExternalFileDrops() { + XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL])) + } +} + +@MainActor +final class BrowserPaneDropRoutingTests: XCTestCase { + func testVerticalZonesFollowAppKitCoordinates() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size), + .top + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size), + .bottom + ) + } + + func testHitTestingCapturesOnlyForRelevantDragEvents() { + XCTAssertTrue( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .cursorUpdate + ) + ) + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .leftMouseDown + ) + ) + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [.fileURL], + eventType: .cursorUpdate + ) + ) + } + + func testCenterDropOnSamePaneIsNoOp() { + let paneId = PaneID(id: UUID()) + let target = BrowserPaneDropContext( + workspaceId: UUID(), + panelId: UUID(), + paneId: paneId + ) + let transfer = BrowserPaneDragTransfer( + tabId: UUID(), + sourcePaneId: paneId.id, + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + XCTAssertEqual( + BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center), + .noOp + ) + } + + func testRightEdgeDropBuildsSplitMoveAction() { + let paneId = PaneID(id: UUID()) + let target = BrowserPaneDropContext( + workspaceId: UUID(), + panelId: UUID(), + paneId: paneId + ) + let tabId = UUID() + let transfer = BrowserPaneDragTransfer( + tabId: tabId, + sourcePaneId: UUID(), + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + XCTAssertEqual( + BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right), + .move( + tabId: tabId, + targetWorkspaceId: target.workspaceId, + targetPane: paneId, + splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) + ) + ) + } + + func testDecodeTransferPayloadReadsTabAndSourcePane() { + let tabId = UUID() + let sourcePaneId = UUID() + let payload = try! JSONSerialization.data( + withJSONObject: [ + "tab": ["id": tabId.uuidString], + "sourcePaneId": sourcePaneId.uuidString, + "sourceProcessId": ProcessInfo.processInfo.processIdentifier, + ] + ) + + let transfer = BrowserPaneDragTransfer.decode(from: payload) + + XCTAssertEqual(transfer?.tabId, tabId) + XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId) + XCTAssertTrue(transfer?.isFromCurrentProcess == true) + } +} + +@MainActor +final class WindowBrowserSlotViewTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private func advanceAnimations() { + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + + func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let child = CapturingView(frame: slot.bounds) + child.autoresizingMask = [.width, .height] + slot.addSubview(child) + + slot.setDropZoneOverlay(zone: .right) + slot.layoutSubtreeIfNeeded() + + guard let overlay = slot.subviews.first(where: { + $0 !== child && 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") + XCTAssertFalse(overlay.isHidden) + XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 96, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 92, accuracy: 0.5) + XCTAssertNil(overlay.hitTest(NSPoint(x: 120, y: 50)), "Overlay should never intercept pointer hits") + XCTAssertTrue(slot.hitTest(NSPoint(x: 120, y: 50)) === child) + + slot.setDropZoneOverlay(zone: nil) + advanceAnimations() + XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay") + } } @MainActor @@ -8817,6 +9261,54 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { ) } + func testTerminalPortalHostStaysBelowBrowserPortalHostWhenBothAreInstalled() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + let browserPortal = WindowBrowserPortal(window: window) + let terminalPortal = WindowTerminalPortal(window: window) + _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + func assertHostOrder(_ message: String) { + guard let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }), + let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }) else { + XCTFail("Expected both portal hosts in same container") + return + } + + XCTAssertLessThan( + terminalHostIndex, + browserHostIndex, + message + ) + } + + assertHostOrder("Terminal portal host should start below browser portal host") + + let anchor = NSView(frame: NSRect(x: 24, y: 24, width: 220, height: 150)) + contentView.addSubview(anchor) + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + terminalPortal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + terminalPortal.synchronizeHostedViewForAnchor(anchor) + + assertHostOrder("Terminal portal bind/sync should not rise above the browser portal host") + } + func testRegistryPrunesPortalWhenWindowCloses() { let baseline = TerminalWindowPortalRegistry.debugPortalCount() let window = NSWindow( @@ -9057,6 +9549,15 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { @MainActor final class BrowserWindowPortalLifecycleTests: XCTestCase { + private final class TrackingPortalWebView: WKWebView { + private(set) var displayIfNeededCount = 0 + + override func displayIfNeeded() { + displayIfNeededCount += 1 + super.displayIfNeeded() + } + } + private func realizeWindowLayout(_ window: NSWindow) { window.makeKeyAndOrderFront(nil) window.displayIfNeeded() @@ -9065,6 +9566,16 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { window.contentView?.layoutSubtreeIfNeeded() } + private func advanceAnimations() { + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + + private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? { + slot.subviews.first(where: { + $0 !== webView && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) + } + func testPortalHostInstallsAboveContentViewForVisibility() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -9095,6 +9606,60 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { ) } + func testBrowserPortalHostStaysAboveTerminalPortalHostDuringPortalChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + + let browserPortal = WindowBrowserPortal(window: window) + let terminalPortal = WindowTerminalPortal(window: window) + _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + func assertHostOrder(_ message: String) { + guard let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), + let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }) else { + XCTFail("Expected both portal hosts in same container") + return + } + + XCTAssertGreaterThan( + browserHostIndex, + terminalHostIndex, + message + ) + } + + assertHostOrder("Browser portal host should start above terminal portal host") + + let terminalAnchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 140)) + contentView.addSubview(terminalAnchor) + let terminalHostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + terminalPortal.bind(hostedView: terminalHostedView, to: terminalAnchor, visibleInUI: true) + terminalPortal.synchronizeHostedViewForAnchor(terminalAnchor) + assertHostOrder("Terminal portal sync should not rise above the browser portal host") + + let browserAnchor = NSView(frame: NSRect(x: 240, y: 20, width: 220, height: 140)) + contentView.addSubview(browserAnchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + browserPortal.bind(webView: webView, to: browserAnchor, visibleInUI: true) + browserPortal.synchronizeWebViewForAnchor(browserAnchor) + assertHostOrder("Browser portal sync should keep browser panes above portal-hosted terminals") + } + func testAnchorRebindKeepsWebViewInStablePortalSuperview() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), @@ -9175,6 +9740,46 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5) } + func testPortalClipsAnchorFrameThroughAncestorBounds() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let clipView = NSView(frame: NSRect(x: 60, y: 40, width: 150, height: 120)) + contentView.addSubview(clipView) + + // Simulate SwiftUI/AppKit reporting an anchor wider than the actual visible pane. + let anchor = NSView(frame: NSRect(x: -30, y: 0, width: 220, height: 120)) + clipView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + clipView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + XCTAssertFalse(slot.isHidden, "Ancestor clipping should keep the browser visible in the real pane") + XCTAssertEqual(slot.frame.origin.x, 60, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 40, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 150, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 120, accuracy: 0.5) + } + func testPortalSyncNormalizesOutOfBoundsWebFrame() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), @@ -9245,6 +9850,154 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync") } + func testPortalDropZoneOverlayPersistsAcrossVisibilityChanges() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView, + let overlay = dropZoneOverlay(in: slot, excluding: webView) else { + XCTFail("Expected browser slot overlay") + return + } + + XCTAssertTrue(overlay.isHidden, "Overlay should start hidden without an active drop zone") + + 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) + XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5) + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + XCTAssertTrue(overlay.isHidden, "Invisible browser entries should hide the overlay") + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + XCTAssertFalse(overlay.isHidden, "Restoring visibility should restore the active drop-zone overlay") + } + + func testPortalRevealRefreshesHostedWebViewWithoutFrameDelta() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + let initialDisplayCount = webView.displayIfNeededCount + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + let hiddenDisplayCount = webView.displayIfNeededCount + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) + portal.synchronizeWebViewForAnchor(anchor) + advanceAnimations() + + XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + hiddenDisplayCount, + "Revealing an existing portal-hosted browser should refresh WebKit presentation immediately" + ) + } + + func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let anchor1 = NSView(frame: anchorFrame) + contentView.addSubview(anchor1) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor1, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor1) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + anchor1.removeFromSuperview() + portal.synchronizeWebViewForAnchor(anchor1) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal") + XCTAssertTrue( + slot.isHidden, + "Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane" + ) + XCTAssertEqual(portal.debugEntryCount(), 1) + + let displayCountBeforeRebind = webView.displayIfNeededCount + let anchor2 = NSView(frame: anchorFrame) + contentView.addSubview(anchor2) + portal.bind(webView: webView, to: anchor2, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor2) + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot") + XCTAssertFalse(slot.isHidden) + XCTAssertEqual(portal.debugEntryCount(), 1) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + displayCountBeforeRebind, + "Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged" + ) + } + func testRegistryDetachRemovesPortalHostedWebView() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -9271,6 +10024,57 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { } } +@MainActor +final class FileDropOverlayViewTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + func testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + realizeWindowLayout(window) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + + let overlay = FileDropOverlayView(frame: container.bounds) + overlay.autoresizingMask = [.width, .height] + container.addSubview(overlay, positioned: .above, relativeTo: nil) + + let point = anchor.convert( + NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), + to: nil + ) + XCTAssertTrue( + overlay.webViewUnderPoint(point) === webView, + "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView" + ) + } +} + final class BrowserLinkOpenSettingsTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! @@ -9928,6 +10732,7 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { return fd } + @MainActor func testSocketListenerHealthRecognizesSocketPath() throws { let path = makeTempSocketPath() let fd = try bindUnixSocket(at: path) @@ -9941,6 +10746,7 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { XCTAssertFalse(health.isHealthy) } + @MainActor func testSocketListenerHealthRejectsRegularFile() throws { let path = makeTempSocketPath() let url = URL(fileURLWithPath: path) @@ -9957,10 +10763,16 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { isRunning: true, acceptLoopAlive: true, socketPathMatches: true, - socketPathExists: true + socketPathExists: true, + socketProbePerformed: true, + socketConnectable: true, + socketConnectErrno: nil ) XCTAssertTrue(health.isHealthy) - XCTAssertEqual(health.failureSignals, []) + XCTAssertTrue(health.failureSignals.isEmpty) + XCTAssertTrue(health.socketProbePerformed) + XCTAssertEqual(health.socketConnectable, true) + XCTAssertNil(health.socketConnectErrno) } func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { @@ -9968,9 +10780,15 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { isRunning: false, acceptLoopAlive: false, socketPathMatches: false, - socketPathExists: false + socketPathExists: false, + socketProbePerformed: false, + socketConnectable: nil, + socketConnectErrno: nil ) XCTAssertFalse(health.isHealthy) + XCTAssertFalse(health.socketProbePerformed) + XCTAssertNil(health.socketConnectable) + XCTAssertNil(health.socketConnectErrno) XCTAssertEqual( health.failureSignals, ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index 4ed0a584..9cd9f038 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -423,6 +423,180 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) } + func testCmdOptionPaneSwitchPreservesFindFieldFocus() { + runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: false) + } + + func testCmdCtrlPaneSwitchPreservesFindFieldFocus() { + runFindFocusPersistenceScenario(route: .cmdCtrlLetters, useAutofocusRacePage: false) + } + + func testCmdOptionPaneSwitchPreservesFindFieldFocusDuringPageAutofocusRace() { + runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: true) + } + + private enum FindFocusRoute { + case cmdOptionArrows + case cmdCtrlLetters + } + + private func runFindFocusPersistenceScenario(route: FindFocusRoute, useAutofocusRacePage: Bool) { + let app = XCUIApplication() + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + if route == .cmdCtrlLetters { + app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" + } + launchAndEnsureForeground(app) + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist") + + // Repro setup: split, open browser split, navigate to example.com. + app.typeKey("d", modifierFlags: [.command]) + focusRightPaneForFindScenario(app, route: route) + + app.typeKey("l", modifierFlags: [.command, .shift]) + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+Shift+L") + + app.typeKey("a", modifierFlags: [.command]) + app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) + if useAutofocusRacePage { + app.typeText(autofocusRacePageURL) + } else { + app.typeText("example.com") + } + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + if useAutofocusRacePage { + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0), + "Expected browser navigation to data URL before running find flow. value=\(String(describing: omnibar.value))" + ) + } else { + XCTAssertTrue( + waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0), + "Expected browser navigation to example domain before running find flow. value=\(String(describing: omnibar.value))" + ) + } + + // Left terminal: Cmd+F then type "la". + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["focusedPanelKind"] == "terminal" + }, + "Expected left terminal pane to be focused before terminal find. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("la") + + // Right browser: Cmd+F then type "am". + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "right" + && data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "la" + }, + "Expected terminal find query to persist as 'la' after focusing browser pane. data=\(String(describing: loadData()))" + ) + app.typeKey("f", modifierFlags: [.command]) + app.typeText("am") + + if useAutofocusRacePage { + XCTAssertTrue( + waitForOmnibarToContain(omnibar, value: "#focused", timeout: 5.0), + "Expected autofocus race page to signal focus handoff via URL hash. value=\(String(describing: omnibar.value))" + ) + } + + // Left terminal: typing should keep going into terminal find field. + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "left" + && data["focusedPanelKind"] == "terminal" + && data["browserFindNeedle"] == "am" + }, + "Expected browser find query to persist as 'am' after returning left. data=\(String(describing: loadData()))" + ) + app.typeText("foo") + + // Right browser: typing should keep going into browser find field. + focusRightPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "right" + && data["focusedPanelKind"] == "browser" + && data["terminalFindNeedle"] == "lafoo" + }, + "Expected terminal find query to stay focused and become 'lafoo'. data=\(String(describing: loadData()))" + ) + app.typeText("do") + + // Move left once more so the recorder captures browser find state after typing. + focusLeftPaneForFindScenario(app, route: route) + XCTAssertTrue( + waitForDataMatch(timeout: 6.0) { data in + data["lastMoveDirection"] == "left" + && data["focusedPanelKind"] == "terminal" + && data["browserFindNeedle"] == "amdo" + }, + "Expected browser find query to stay focused and become 'amdo'. data=\(String(describing: loadData()))" + ) + } + + private func focusLeftPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { + switch route { + case .cmdOptionArrows: + app.typeKey(XCUIKeyboardKey.leftArrow.rawValue, modifierFlags: [.command, .option]) + case .cmdCtrlLetters: + app.typeKey("h", modifierFlags: [.command, .control]) + } + } + + private func focusRightPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { + switch route { + case .cmdOptionArrows: + app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: [.command, .option]) + case .cmdCtrlLetters: + app.typeKey("l", modifierFlags: [.command, .control]) + } + } + + private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let value = (omnibar.value as? String) ?? "" + if value.contains("example.com") || value.contains("example.org") { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + let value = (omnibar.value as? String) ?? "" + return value.contains("example.com") || value.contains("example.org") + } + + private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let value = (omnibar.value as? String) ?? "" + if value.contains(expectedSubstring) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + let value = (omnibar.value as? String) ?? "" + return value.contains(expectedSubstring) + } + + private var autofocusRacePageURL: String { + "data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E" + } + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { app.launch() XCTAssertTrue( diff --git a/docs/assets/split-cwd-inheritance-demo.gif b/docs/assets/split-cwd-inheritance-demo.gif new file mode 100644 index 00000000..5a1c1c9c Binary files /dev/null and b/docs/assets/split-cwd-inheritance-demo.gif differ 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/regression_helpers.py b/tests/regression_helpers.py new file mode 100644 index 00000000..73965c51 --- /dev/null +++ b/tests/regression_helpers.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Shared helpers for static regression tests.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + git = shutil.which("git") + if git is None: + return Path(__file__).resolve().parents[1] + try: + result = subprocess.run( + [git, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + timeout=2, + ) + except (subprocess.TimeoutExpired, OSError): + return Path(__file__).resolve().parents[1] + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + # Targeted helper for this regression suite: assumes braces in the matched + # block are structural (not inside strings/comments/character literals). + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + + raise ValueError(f"Unbalanced braces for: {signature}") diff --git a/tests/test_browser_find_overlay_portal_regression.py b/tests/test_browser_find_overlay_portal_regression.py new file mode 100644 index 00000000..468a1892 --- /dev/null +++ b/tests/test_browser_find_overlay_portal_regression.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Regression guards for browser Cmd+F overlay layering in portal mode.""" + +from __future__ import annotations + +from regression_helpers import extract_block, repo_root + + +def main() -> int: + root = repo_root() + view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" + panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" + overlay_path = root / "Sources" / "Find" / "BrowserSearchOverlay.swift" + source = view_path.read_text(encoding="utf-8") + panel_source = panel_path.read_text(encoding="utf-8") + overlay_source = overlay_path.read_text(encoding="utf-8") + failures: list[str] = [] + + try: + browser_panel_view_block = extract_block( + source, "struct BrowserPanelView: View" + ) + except ValueError as error: + failures.append(str(error)) + browser_panel_view_block = "" + + try: + body_block = extract_block(browser_panel_view_block, "var body: some View") + except ValueError as error: + failures.append(str(error)) + body_block = "" + + fallback_signature = ( + "if !panel.shouldRenderWebView, let searchState = panel.searchState {" + ) + fallback_block = "" + if body_block: + try: + fallback_block = extract_block(body_block, fallback_signature) + except ValueError: + failures.append( + "BrowserPanelView must provide BrowserSearchOverlay fallback for new-tab state " + "(when WKWebView is not mounted)" + ) + if fallback_block and "BrowserSearchOverlay(" not in fallback_block: + failures.append( + "BrowserPanelView fallback branch must mount BrowserSearchOverlay for new-tab state" + ) + + try: + webview_repr_block = extract_block( + source, "struct WebViewRepresentable: NSViewRepresentable" + ) + except ValueError as error: + failures.append(str(error)) + webview_repr_block = "" + + if webview_repr_block: + if "let browserSearchState: BrowserSearchState?" not in webview_repr_block: + failures.append( + "WebViewRepresentable must include browserSearchState so Cmd+F state changes trigger updates" + ) + if ( + "var searchOverlayHostingView: NSHostingView?" + not in webview_repr_block + ): + failures.append( + "WebViewRepresentable.Coordinator must own a BrowserSearchOverlay hosting view" + ) + if "private static func updateSearchOverlay(" not in webview_repr_block: + failures.append( + "WebViewRepresentable must define updateSearchOverlay helper" + ) + if "containerView: webView.superview" not in webview_repr_block: + failures.append( + "Portal updates must sync BrowserSearchOverlay against the web view container" + ) + if "removeSearchOverlay(from: coordinator)" not in webview_repr_block: + failures.append( + "WebViewRepresentable must remove browser search overlays during teardown/rebind" + ) + + if "browserSearchState: panel.searchState" not in source: + failures.append( + "BrowserPanelView must pass panel.searchState into WebViewRepresentable" + ) + + try: + update_ns_view_block = extract_block( + webview_repr_block, "func updateNSView(_ nsView: NSView, context: Context)" + ) + except ValueError as error: + failures.append(str(error)) + update_ns_view_block = "" + + if "updateSearchOverlay(" in update_ns_view_block: + failures.append( + "updateNSView must not re-run updateSearchOverlay outside portal lifecycle paths" + ) + + try: + suppress_focus_block = extract_block( + panel_source, "func shouldSuppressWebViewFocus() -> Bool" + ) + except ValueError as error: + failures.append(str(error)) + suppress_focus_block = "" + + if "if searchState != nil {" not in suppress_focus_block: + failures.append( + "BrowserPanel.shouldSuppressWebViewFocus must suppress focus while find-in-page is active" + ) + + try: + start_find_block = extract_block(panel_source, "func startFind()") + except ValueError as error: + failures.append(str(error)) + start_find_block = "" + + if start_find_block: + if "postBrowserSearchFocusNotification()" not in start_find_block: + failures.append( + "BrowserPanel.startFind must publish browserSearchFocus notifications" + ) + if "DispatchQueue.main.async {" not in start_find_block: + failures.append( + "BrowserPanel.startFind must re-post focus on next runloop to avoid mount races" + ) + if "DispatchQueue.main.asyncAfter" not in start_find_block: + failures.append( + "BrowserPanel.startFind must re-post focus shortly after to avoid portal mount races" + ) + + try: + init_block = extract_block(panel_source, "init(workspaceId: UUID") + except ValueError as error: + failures.append(str(error)) + init_block = "" + + if init_block: + if ( + "self?.searchState = nil" in init_block + or "self.searchState = nil" in init_block + ): + failures.append( + "BrowserPanel navigation callbacks must not clear searchState entirely to avoid losing find bar focus" + ) + if "restoreFindStateAfterNavigation(replaySearch: true)" not in init_block: + failures.append( + "BrowserPanel.didFinish must preserve find state and replay search on the new page" + ) + if "restoreFindStateAfterNavigation(replaySearch: false)" not in init_block: + failures.append( + "BrowserPanel.didFailNavigation must preserve find state without replaying search" + ) + + try: + restore_find_state_block = extract_block( + panel_source, "private func restoreFindStateAfterNavigation(replaySearch: Bool)" + ) + except ValueError as error: + failures.append(str(error)) + restore_find_state_block = "" + + if restore_find_state_block: + if "state.total = nil" not in restore_find_state_block: + failures.append( + "BrowserPanel restoreFindStateAfterNavigation must clear stale find total count" + ) + if "state.selected = nil" not in restore_find_state_block: + failures.append( + "BrowserPanel restoreFindStateAfterNavigation must clear stale selected match" + ) + if "if replaySearch, !state.needle.isEmpty {" not in restore_find_state_block: + failures.append( + "BrowserPanel restoreFindStateAfterNavigation must only replay search for successful navigations" + ) + if "postBrowserSearchFocusNotification()" not in restore_find_state_block: + failures.append( + "BrowserPanel restoreFindStateAfterNavigation must reassert find field focus" + ) + + if "private func requestSearchFieldFocus(" not in overlay_source: + failures.append( + "BrowserSearchOverlay must define requestSearchFieldFocus retry helper" + ) + if "requestSearchFieldFocus()" not in overlay_source: + failures.append( + "BrowserSearchOverlay must request text focus from appear/notification paths" + ) + + if failures: + print("FAIL: browser find overlay portal regression guards failed") + for failure in failures: + print(f" - {failure}") + return 1 + + print("PASS: browser find overlay remains mounted in portal-hosted AppKit layer") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_focus_notification_dismiss.py b/tests/test_focus_notification_dismiss.py index d7569b65..14cef434 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 clears its notification and triggers a flash. +E2E: focusing a panel preserves its notification and triggers a flash. Note: This uses the socket focus command (no assistive access needed). """ @@ -74,8 +74,12 @@ def main() -> int: client.send("x") time.sleep(0.2) - if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0): - print("FAIL: Notification did not become read after focus") + 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") return 1 final_flash = client.flash_count(term_b) @@ -93,7 +97,7 @@ def main() -> int: except Exception: pass - print("PASS: Focus clears notification and flashes panel") + print("PASS: Focus preserves notification and flashes panel") return 0 except (cmuxError, RuntimeError) as exc: print(f"FAIL: {exc}") diff --git a/tests/test_issue_666_sidebar_branch_checkout_refresh.py b/tests/test_issue_666_sidebar_branch_checkout_refresh.py new file mode 100644 index 00000000..751a8c70 --- /dev/null +++ b/tests/test_issue_666_sidebar_branch_checkout_refresh.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Regression guard for issue #666 (sidebar branch stuck after checkout).""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + zsh_path = repo_root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh" + bash_path = repo_root / "Resources" / "shell-integration" / "cmux-bash-integration.bash" + + required_paths = [zsh_path, bash_path] + missing_paths = [str(path) for path in required_paths if not path.exists()] + if missing_paths: + print("Missing expected files:") + for path in missing_paths: + print(f" - {path}") + return 1 + + zsh_content = zsh_path.read_text(encoding="utf-8") + bash_content = bash_path.read_text(encoding="utf-8") + + failures: list[str] = [] + + require( + zsh_content, + "_CMUX_GIT_HEAD_SIGNATURE", + "zsh integration is missing git HEAD signature tracking", + failures, + ) + require( + zsh_content, + "_cmux_git_head_signature", + "zsh integration is missing git HEAD signature helper", + failures, + ) + require( + zsh_content, + '"$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE"', + "zsh integration no longer compares git HEAD signatures", + failures, + ) + require( + zsh_content, + "_CMUX_GIT_FORCE=1", + "zsh integration no longer forces git probe refresh on HEAD changes", + failures, + ) + + require( + bash_content, + "_CMUX_GIT_HEAD_SIGNATURE", + "bash integration is missing git HEAD signature tracking", + failures, + ) + require( + bash_content, + "_cmux_git_head_signature", + "bash integration is missing git HEAD signature helper", + failures, + ) + require( + bash_content, + "git_head_changed=1", + "bash integration no longer flags HEAD changes for immediate refresh", + failures, + ) + require( + bash_content, + '|| "$git_head_changed" == "1"', + "bash integration no longer restarts running git probes on HEAD change", + failures, + ) + + if failures: + print("FAIL: issue #666 regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: issue #666 checkout refresh guards are present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_issue_952_socket_listener_recovery.py b/tests/test_issue_952_socket_listener_recovery.py new file mode 100644 index 00000000..46a83644 --- /dev/null +++ b/tests/test_issue_952_socket_listener_recovery.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Regression guard for issue #952 (flaky CLI socket connections).""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + """Return the repository root for source inspections.""" + fallback_root = Path(__file__).resolve().parents[1] + git_path = shutil.which("git") + if git_path is None: + return fallback_root + + try: + result = subprocess.run( + [git_path, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return fallback_root + if result.returncode == 0: + return Path(result.stdout.strip()) + return fallback_root + + +def require(content: str, needle: str, message: str, failures: list[str], *, regex: bool = False) -> None: + """Record a failure when a required source pattern is missing.""" + matched = re.search(needle, content, re.MULTILINE) is not None if regex else needle in content + if not matched: + failures.append(message) + + +def collect_failures() -> list[str]: + """Collect missing source-level guards for the socket listener recovery fix.""" + repo_root = get_repo_root() + terminal_controller_path = repo_root / "Sources" / "TerminalController.swift" + app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" + failures: list[str] = [] + + missing_paths = [ + str(path) for path in [terminal_controller_path, app_delegate_path] if not path.exists() + ] + if missing_paths: + for path in missing_paths: + failures.append(f"Missing expected file: {path}") + return failures + + terminal_controller = terminal_controller_path.read_text(encoding="utf-8") + app_delegate = app_delegate_path.read_text(encoding="utf-8") + + require( + terminal_controller, + "let socketProbePerformed: Bool", + "Socket health snapshot no longer tracks whether connectability was probed", + failures, + ) + require( + terminal_controller, + "let socketConnectable: Bool?", + "Socket health snapshot no longer distinguishes unprobed vs connectable sockets", + failures, + ) + require( + terminal_controller, + "let socketConnectErrno: Int32?", + "Socket health snapshot no longer preserves probe errno", + failures, + ) + require( + terminal_controller, + "signals.append(\"socket_unreachable\")", + "Socket health failures no longer flag unreachable listeners", + failures, + ) + require( + terminal_controller, + r"private\s+nonisolated\s+static\s+func\s+probeSocketConnectability\s*\(\s*path:\s*String\s*\)", + "Missing active socket connectability probe helper", + failures, + regex=True, + ) + require( + terminal_controller, + r"connect\s*\(\s*probeSocket\s*,\s*sockaddrPtr\s*,\s*socklen_t\s*\(\s*MemoryLayout\.size\s*\)\s*\)", + "Socket health probe no longer performs a real connect() check", + failures, + regex=True, + ) + require( + terminal_controller, + "stage: \"bind_path_too_long\"", + "Socket listener start no longer reports overlong Unix socket paths", + failures, + ) + require( + terminal_controller, + "Self.unixSocketPathMaxLength", + "Socket listener path-length telemetry was removed", + failures, + ) + + require( + app_delegate, + "private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2)", + "Socket health timer interval drifted from the fast recovery setting", + failures, + ) + require( + app_delegate, + "\"socketProbePerformed\": health.socketProbePerformed ? 1 : 0", + "Health telemetry no longer records whether a connectability probe ran", + failures, + ) + require( + app_delegate, + "if let socketConnectable = health.socketConnectable {", + "Health telemetry no longer gates connectability on an actual probe result", + failures, + ) + require( + app_delegate, + "data[\"socketConnectable\"] = socketConnectable ? 1 : 0", + "Health telemetry no longer includes connectability when a probe ran", + failures, + ) + require( + app_delegate, + "if let socketConnectErrno = health.socketConnectErrno {", + "Health telemetry no longer records connect probe errno when available", + failures, + ) + return failures + + +def test_issue_952_socket_listener_recovery() -> None: + """Keep the source-level recovery guards for issue #952 in CI.""" + failures = collect_failures() + assert not failures, "issue #952 regression(s) detected:\n- " + "\n- ".join(failures) + + +def main() -> int: + """Run the regression guard without requiring pytest to be installed.""" + failures = collect_failures() + if failures: + print("FAIL: issue #952 regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: issue #952 socket listener recovery guards are present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 1ac25c4b..23b4bf10 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -58,6 +58,15 @@ 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: @@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult: return result -def test_mark_read_on_focus_change(client: cmux) -> TestResult: - result = TestResult("Mark Read On Panel Focus") +def test_preserve_unread_on_focus_change(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On Panel Focus") try: client.clear_notifications() client.reset_flash_counts() @@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult: client.set_app_focus(False) client.notify_surface(other[0], "focusread") - time.sleep(0.1) + 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 client.set_app_focus(True) client.focus_surface(other[0]) - time.sleep(0.1) + 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 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 not target["is_read"]: - result.failure("Expected notification to be marked read on focus") + elif target["is_read"]: + result.failure("Expected notification to remain unread on focus") else: - 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") + result.success("Notification persisted across panel focus") except Exception as e: result.failure(f"Exception: {e}") return result -def test_mark_read_on_app_active(client: cmux) -> TestResult: - result = TestResult("Mark Read On App Active") +def test_preserve_unread_on_app_active(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On App Active") try: client.clear_notifications() client.set_app_focus(False) client.notify("activate") - time.sleep(0.1) - - items = client.list_notifications() + items = wait_for_notifications(client, 1) if not items or items[0]["is_read"]: result.failure("Expected unread notification before activation") return result client.simulate_app_active() - time.sleep(0.1) - - items = client.list_notifications() + items = wait_for_notifications(client, 1) if not items: result.failure("Expected notification to remain after activation") - elif not items[0]["is_read"]: - result.failure("Expected notification to be marked read on app active") + elif items[0]["is_read"]: + result.failure("Expected notification to remain unread on app active") else: - result.success("Notification marked read on app active") + result.success("Notification persisted across app activation") except Exception as e: result.failure(f"Exception: {e}") return result -def test_mark_read_on_tab_switch(client: cmux) -> TestResult: - result = TestResult("Mark Read On Tab Switch") +def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On Tab Switch") try: client.clear_notifications() client.set_app_focus(False) tab1 = client.current_workspace() client.notify("tabswitch") - time.sleep(0.1) + 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 tab2 = client.new_workspace() - time.sleep(0.1) + if not wait_for_current_workspace(client, tab2): + result.failure("Expected new workspace to become selected") + return result client.set_app_focus(True) client.select_workspace(tab1) - time.sleep(0.1) + if not wait_for_current_workspace(client, tab1): + result.failure("Expected original workspace to become selected again") + return result - items = client.list_notifications() + items = wait_for_notifications(client, 1) 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 not target["is_read"]: - result.failure("Expected notification to be marked read on tab switch") + elif target["is_read"]: + result.failure("Expected notification to remain unread on tab switch") else: - result.success("Notification marked read on tab switch") + result.success("Notification persisted across tab switch") except Exception as e: result.failure(f"Exception: {e}") return result @@ -371,11 +387,20 @@ 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 and flashes panel") + result.success("Notification click focuses, flashes, and preserves unread state") except Exception as e: result.failure(f"Exception: {e}") return result @@ -455,9 +480,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_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_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_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/test_split_cwd_inheritance.py b/tests/test_split_cwd_inheritance.py new file mode 100644 index 00000000..6677ee8e --- /dev/null +++ b/tests/test_split_cwd_inheritance.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +End-to-end test for split CWD inheritance. + +Verifies that new split panes and new workspace tabs inherit the current +working directory from the source terminal. + +Requires: + - cmux running with allowAll socket mode + - bash shell integration sourced (cmux-bash-integration.bash) + +Run with a tagged instance: + CMUX_TAG= python3 tests/test_split_cwd_inheritance.py +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for(predicate, timeout: float, interval: float, label: str): + start = time.time() + last_error: Exception | None = None + while time.time() - start < timeout: + try: + value = predicate() + if value: + return value + except Exception as e: + last_error = e + time.sleep(interval) + extra = "" + if last_error is not None: + extra = f" Last error: {last_error}" + raise AssertionError(f"Timed out waiting for {label}.{extra}") + + +def _wait_for_focused_cwd( + client: cmux, + expected: str, + timeout: float = 12.0, + exclude_panel: str | None = None, +) -> dict[str, str]: + """Wait for focused_cwd to match expected. + + If exclude_panel is given, also require that focused_panel differs from + that value — ensuring we're checking the *new* pane, not the original. + """ + def pred(): + state = _parse_sidebar_state(client.sidebar_state()) + cwd = state.get("focused_cwd", "") + if cwd != expected: + return None + if exclude_panel and state.get("focused_panel", "") == exclude_panel: + return None + return state + label = f"focused_cwd={expected!r}" + if exclude_panel: + label += f" (panel != {exclude_panel})" + return _wait_for(pred, timeout=timeout, interval=0.3, label=label) + + +def _send_cd_and_wait( + client: cmux, + target: str, + timeout: float = 12.0, +) -> dict[str, str]: + """cd to target and wait for sidebar focused_cwd to reflect it.""" + client.send(f"cd {target}\n") + return _wait_for_focused_cwd(client, target, timeout=timeout) + + +def main() -> int: + tag = os.environ.get("CMUX_TAG", "") + + socket_path = None + if tag: + socket_path = f"/tmp/cmux-debug-{tag}.sock" + client = cmux(socket_path=socket_path) + client.connect() + + # Use resolved paths to avoid /tmp -> /private/tmp symlink mismatch on macOS + test_dir_a = str(Path("/tmp/cmux_split_cwd_test_a").resolve()) + test_dir_b = str(Path("/tmp/cmux_split_cwd_test_b").resolve()) + os.makedirs(test_dir_a, exist_ok=True) + os.makedirs(test_dir_b, exist_ok=True) + + passed = 0 + failed = 0 + + def check(name: str, condition: bool, detail: str = ""): + nonlocal passed, failed + if condition: + print(f" PASS {name}") + passed += 1 + else: + print(f" FAIL {name}{': ' + detail if detail else ''}") + failed += 1 + + print("=== Split CWD Inheritance Tests ===") + + # --- Setup: cd to test_dir_a in workspace 1 --- + print(" [setup] cd to test_dir_a and wait for shell integration...") + _send_cd_and_wait(client, test_dir_a) + state = _parse_sidebar_state(client.sidebar_state()) + check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a, + f"got {state.get('focused_cwd')!r}") + + # --- Test 1: New split inherits test_dir_a --- + print(" [test1] creating right split from test_dir_a...") + # Record the original panel so we can verify focus moves to the NEW pane. + original_panel = state.get("focused_panel", "") + split_result = client.new_split("right") + if not split_result: + check("split created", False) + print(f"\n{passed} passed, {failed} failed") + client.close() + return 1 + check("split created", True) + + # Wait for the NEW pane (different panel ID) to report test_dir_a. + time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND + try: + state = _wait_for_focused_cwd( + client, test_dir_a, timeout=15.0, exclude_panel=original_panel, + ) + new_panel = state.get("focused_panel", "") + check("test1: focus moved to new pane", new_panel != original_panel, + f"original={original_panel!r}, current={new_panel!r}") + check("test1: split inherited test_dir_a", + state.get("focused_cwd") == test_dir_a, + f"focused_cwd={state.get('focused_cwd')!r}") + except AssertionError: + state = _parse_sidebar_state(client.sidebar_state()) + check("test1: split inherited test_dir_a", False, + f"focused_cwd={state.get('focused_cwd')!r}, focused_panel={state.get('focused_panel')!r}") + + # --- Test 2: New workspace tab inherits CWD --- + # First cd to test_dir_b so we have a different dir to inherit + print(" [test2] cd to test_dir_b, then creating new workspace tab...") + _send_cd_and_wait(client, test_dir_b) + state = _parse_sidebar_state(client.sidebar_state()) + original_tab = state.get("tab", "") + + tab_result = client.new_tab() + if not tab_result: + check("new tab created", False) + print(f"\n{passed} passed, {failed} failed") + client.close() + return 1 + check("new tab created", True) + + # New workspace should be a different tab AND inherit test_dir_b + time.sleep(4) + try: + def _new_tab_with_cwd(): + s = _parse_sidebar_state(client.sidebar_state()) + tab_id = s.get("tab", "") + cwd = s.get("focused_cwd", "") + if tab_id != original_tab and cwd == test_dir_b: + return s + return None + + state = _wait_for( + _new_tab_with_cwd, timeout=15.0, interval=0.3, + label=f"new tab with focused_cwd={test_dir_b!r}", + ) + check("test2: focus moved to new tab", state.get("tab") != original_tab, + f"original={original_tab!r}, current={state.get('tab')!r}") + check("test2: new workspace inherited test_dir_b", + state.get("focused_cwd") == test_dir_b, + f"focused_cwd={state.get('focused_cwd')!r}") + except AssertionError: + state = _parse_sidebar_state(client.sidebar_state()) + check("test2: new workspace inherited test_dir_b", False, + f"focused_cwd={state.get('focused_cwd')!r}, tab={state.get('tab')!r}") + + print(f"\n{passed} passed, {failed} failed") + + client.close() + + # Cleanup + for d in [test_dir_a, test_dir_b]: + try: + os.rmdir(d) + except OSError: + pass + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests_v2/test_browser_cli_agent_port.py b/tests_v2/test_browser_cli_agent_port.py index d8266a66..bbebdeec 100644 --- a/tests_v2/test_browser_cli_agent_port.py +++ b/tests_v2/test_browser_cli_agent_port.py @@ -164,6 +164,12 @@ def main() -> int: ) _must(bool(workspace), f"Expected workspace handle from identify: {identify}") + 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 "") + _must(bool(blank_surface), f"Expected about:blank browser open to return a surface: {blank_opened}") + blank_snapshot = _run_cli_text(cli, ["browser", blank_surface, "snapshot", "--interactive"]) + _must("about:blank" in blank_snapshot and "get url" in blank_snapshot, f"Expected empty snapshot diagnostics for about:blank: {blank_snapshot!r}") + opened_routed = _run_cli_json(cli, ["browser", "open", page_url, "--workspace", workspace]) routed_surface = str(opened_routed.get("surface_ref") or opened_routed.get("surface_id") or "") _must(bool(routed_surface), f"browser open --workspace returned no surface handle: {opened_routed}") diff --git a/tests_v2/test_browser_cli_wait_and_screenshot.py b/tests_v2/test_browser_cli_wait_and_screenshot.py new file mode 100644 index 00000000..fb4d2fb7 --- /dev/null +++ b/tests_v2/test_browser_cli_wait_and_screenshot.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Regression: browser wait/snapshot and screenshot CLI return usable file locations.""" + +import glob +import json +import os +import subprocess +import sys +import tempfile +import urllib.parse +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _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: str) -> subprocess.CompletedProcess[str]: + cmd = [cli, "--socket", SOCKET_PATH, *args] + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc + + +def main() -> int: + cli = _find_cli_binary() + + with cmux(SOCKET_PATH) as c: + opened = c._call("browser.open_split", {"url": "about:blank"}) or {} + target = str(opened.get("surface_id") or opened.get("surface_ref") or "") + _must(target != "", f"browser.open_split returned no surface handle: {opened}") + + html = """ + + + cmux-browser-cli-regression + +
+

browser cli regression

+

ready

+
+ + +""".strip() + data_url = "data:text/html;charset=utf-8," + urllib.parse.quote(html) + c._call("browser.navigate", {"surface_id": target, "url": data_url}) + + wait_proc = _run_cli( + cli, + "browser", + target, + "wait", + "--load-state", + "interactive", + "--timeout-ms", + "5000", + ) + _must(wait_proc.stdout.strip() == "OK", f"Expected browser wait OK output: {wait_proc.stdout!r}") + + snapshot_payload = c._call("browser.snapshot", {"surface_id": target}) or {} + refs = snapshot_payload.get("refs") or {} + _must(isinstance(refs, dict) and len(refs) > 0, f"Expected snapshot refs for ref-based wait coverage: {snapshot_payload}") + ref_selector = str(next(iter(refs.keys()))) + ref_wait_proc = _run_cli( + cli, + "browser", + target, + "wait", + "--selector", + ref_selector, + "--timeout-ms", + "2000", + ) + _must(ref_wait_proc.stdout.strip() == "OK", f"Expected browser wait to resolve snapshot refs: {ref_wait_proc.stdout!r}") + + snapshot_proc = _run_cli(cli, "browser", target, "snapshot", "--compact") + _must( + snapshot_proc.stdout.strip().startswith("- document"), + f"Expected snapshot command to succeed with structured output: {snapshot_proc.stdout!r}", + ) + + screenshot_json_proc = _run_cli(cli, "browser", target, "screenshot", "--json") + screenshot_json_text = screenshot_json_proc.stdout.strip() + payload = json.loads(screenshot_json_text or "{}") + + _must("\\/" not in screenshot_json_text, f"Expected screenshot JSON without escaped slashes: {screenshot_json_text!r}") + _must("png_base64" not in payload, f"Expected screenshot JSON to omit png_base64 when file location is available: {payload}") + + screenshot_path = str(payload.get("path") or "") + screenshot_url = str(payload.get("url") or "") + _must(screenshot_path.startswith("/"), f"Expected screenshot path in JSON payload: {payload}") + _must(screenshot_url.startswith("file://"), f"Expected screenshot file URL in JSON payload: {payload}") + _must(Path(screenshot_path).is_file(), f"Expected screenshot file to exist: {payload}") + + out_dir = Path(tempfile.mkdtemp(prefix="cmux-browser-screenshot-cli-")) / "nested" / "dir" + out_path = out_dir / "capture.png" + screenshot_out_proc = _run_cli( + cli, + "browser", + target, + "screenshot", + "--out", + str(out_path), + ) + _must(screenshot_out_proc.stdout.strip() == f"OK {out_path}", f"Expected --out to print the requested path: {screenshot_out_proc.stdout!r}") + _must("file://" not in screenshot_out_proc.stdout, f"Expected --out to print a path, not a file URL: {screenshot_out_proc.stdout!r}") + _must(out_path.is_file(), f"Expected --out screenshot file to exist: {out_path}") + + print("PASS: browser CLI wait/snapshot and screenshot output work end-to-end") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_browser_skill_get_selector_docs.py b/tests_v2/test_browser_skill_get_selector_docs.py new file mode 100644 index 00000000..92c5a8f9 --- /dev/null +++ b/tests_v2/test_browser_skill_get_selector_docs.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Regression checks for cmux-browser get selector examples.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +ROOT = Path(__file__).resolve().parents[1] +COMMANDS = ROOT / "skills/cmux-browser/references/commands.md" + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def main() -> int: + commands = COMMANDS.read_text(encoding="utf-8") + + _must("`agent-browser get text ` -> `cmux browser get text `" in commands, "Expected get text mapping to mention selector support") + _must("cmux browser get text body" in commands, "Expected get text body example") + _must("cmux browser get html body" in commands, "Expected get html body example") + _must('cmux browser get value "#email"' in commands, "Expected get value selector example") + _must('cmux browser get attr "#email" --attr placeholder' in commands, "Expected get attr selector example") + _must("cmux browser get text|html|value|attr|count|box|styles ..." not in commands, "Unexpected bare get example block") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_browser_skill_js_error_docs.py b/tests_v2/test_browser_skill_js_error_docs.py new file mode 100644 index 00000000..566ab10f --- /dev/null +++ b/tests_v2/test_browser_skill_js_error_docs.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Regression checks for cmux-browser js_error troubleshooting docs.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +ROOT = Path(__file__).resolve().parents[1] +SKILL = ROOT / "skills/cmux-browser/SKILL.md" + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def main() -> int: + skill = SKILL.read_text(encoding="utf-8") + + _must("## Troubleshooting" in skill, "Expected Troubleshooting section in cmux-browser skill") + _must("### `js_error` on `snapshot --interactive` or `eval`" in skill, "Expected js_error troubleshooting heading") + _must("cmux browser surface:7 get url" in skill, "Expected get url recovery step") + _must("cmux browser surface:7 get text body" in skill, "Expected get text body recovery step") + _must("cmux browser surface:7 get html body" in skill, "Expected get html body recovery step") + _must("when `snapshot --interactive` or `eval` returns `js_error`" in skill, "Expected js_error fallback guidance") + _must("navigate to a simpler intermediate page" in skill, "Expected simpler-page fallback guidance") + + 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 d7569b65..14cef434 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 clears its notification and triggers a flash. +E2E: focusing a panel preserves its notification and triggers a flash. Note: This uses the socket focus command (no assistive access needed). """ @@ -74,8 +74,12 @@ def main() -> int: client.send("x") time.sleep(0.2) - if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0): - print("FAIL: Notification did not become read after focus") + 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") return 1 final_flash = client.flash_count(term_b) @@ -93,7 +97,7 @@ def main() -> int: except Exception: pass - print("PASS: Focus clears notification and flashes panel") + print("PASS: Focus preserves 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 1ac25c4b..23b4bf10 100644 --- a/tests_v2/test_notifications.py +++ b/tests_v2/test_notifications.py @@ -58,6 +58,15 @@ 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: @@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult: return result -def test_mark_read_on_focus_change(client: cmux) -> TestResult: - result = TestResult("Mark Read On Panel Focus") +def test_preserve_unread_on_focus_change(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On Panel Focus") try: client.clear_notifications() client.reset_flash_counts() @@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult: client.set_app_focus(False) client.notify_surface(other[0], "focusread") - time.sleep(0.1) + 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 client.set_app_focus(True) client.focus_surface(other[0]) - time.sleep(0.1) + 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 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 not target["is_read"]: - result.failure("Expected notification to be marked read on focus") + elif target["is_read"]: + result.failure("Expected notification to remain unread on focus") else: - 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") + result.success("Notification persisted across panel focus") except Exception as e: result.failure(f"Exception: {e}") return result -def test_mark_read_on_app_active(client: cmux) -> TestResult: - result = TestResult("Mark Read On App Active") +def test_preserve_unread_on_app_active(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On App Active") try: client.clear_notifications() client.set_app_focus(False) client.notify("activate") - time.sleep(0.1) - - items = client.list_notifications() + items = wait_for_notifications(client, 1) if not items or items[0]["is_read"]: result.failure("Expected unread notification before activation") return result client.simulate_app_active() - time.sleep(0.1) - - items = client.list_notifications() + items = wait_for_notifications(client, 1) if not items: result.failure("Expected notification to remain after activation") - elif not items[0]["is_read"]: - result.failure("Expected notification to be marked read on app active") + elif items[0]["is_read"]: + result.failure("Expected notification to remain unread on app active") else: - result.success("Notification marked read on app active") + result.success("Notification persisted across app activation") except Exception as e: result.failure(f"Exception: {e}") return result -def test_mark_read_on_tab_switch(client: cmux) -> TestResult: - result = TestResult("Mark Read On Tab Switch") +def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On Tab Switch") try: client.clear_notifications() client.set_app_focus(False) tab1 = client.current_workspace() client.notify("tabswitch") - time.sleep(0.1) + 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 tab2 = client.new_workspace() - time.sleep(0.1) + if not wait_for_current_workspace(client, tab2): + result.failure("Expected new workspace to become selected") + return result client.set_app_focus(True) client.select_workspace(tab1) - time.sleep(0.1) + if not wait_for_current_workspace(client, tab1): + result.failure("Expected original workspace to become selected again") + return result - items = client.list_notifications() + items = wait_for_notifications(client, 1) 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 not target["is_read"]: - result.failure("Expected notification to be marked read on tab switch") + elif target["is_read"]: + result.failure("Expected notification to remain unread on tab switch") else: - result.success("Notification marked read on tab switch") + result.success("Notification persisted across tab switch") except Exception as e: result.failure(f"Exception: {e}") return result @@ -371,11 +387,20 @@ 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 and flashes panel") + result.success("Notification click focuses, flashes, and preserves unread state") except Exception as e: result.failure(f"Exception: {e}") return result @@ -455,9 +480,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_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_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_flash_on_tab_switch(client)) results.append(test_focus_on_notification_click(client)) results.append(test_restore_focus_on_tab_switch(client))