diff --git a/.claude/commands/release-local.md b/.claude/commands/release-local.md index 8aeea134..1e8dcff9 100644 --- a/.claude/commands/release-local.md +++ b/.claude/commands/release-local.md @@ -9,19 +9,29 @@ Full end-to-end release built locally. Bumps version, updates changelog, tags, t - Get the current version from `GhosttyTabs.xcodeproj/project.pbxproj` (look for `MARKETING_VERSION`) - Bump the minor version unless the user specifies otherwise (e.g., 0.54.0 → 0.55.0) -### 2. Gather changes since the last release +### 2. Gather changes and contributors since the last release - Find the most recent git tag: `git describe --tags --abbrev=0` - Get commits since that tag: `git log --oneline ..HEAD --no-merges` - **Filter for end-user visible changes only** — ignore developer tooling, CI, docs, tests - Categorize changes into: Added, Changed, Fixed, Removed - If there are no user-facing changes, ask the user if they still want to release +- **Collect contributors:** For each PR referenced in the commits, get the author: + ```bash + gh pr view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` +- Also check for linked issue reporters (the person who filed the bug): + ```bash + gh issue view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` +- Build a deduplicated list of all contributor `@handle`s for the release ### 3. Update the changelog - Add a new section at the top of `CHANGELOG.md` with the new version and today's date - **Only include changes that affect the end-user experience** - Write clear, user-facing descriptions (not raw commit messages) +- **Credit contributors inline** (see Contributor Credits below) - Also update `docs-site/content/docs/changelog.mdx` if it exists ### 4. Bump the version @@ -72,3 +82,25 @@ If the script fails, run `say "cmux release failed"`. - Group by category: Added, Changed, Fixed, Removed - Be concise but descriptive - Focus on what the user experiences, not how it was implemented + +## Contributor Credits + +Credit the people who made each release happen. This builds community and encourages contributions. + +**Per-entry attribution** — append contributor credit after each changelog bullet: +- For code contributions (PR author): `— thanks @user!` +- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!` +- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline + +**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release: +```markdown +### Thanks to N contributors! + +- [@user1](https://github.com/user1) +- [@user2](https://github.com/user2) +``` +- List all contributors alphabetically by GitHub handle (including core team) +- Link each handle to their GitHub profile +- Include everyone: PR authors, issue reporters, anyone whose work is in the release + +**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles. diff --git a/.claude/commands/release-nightly.md b/.claude/commands/release-nightly.md index c5ce83dc..36f1a8d2 100644 --- a/.claude/commands/release-nightly.md +++ b/.claude/commands/release-nightly.md @@ -13,16 +13,26 @@ End-to-end release via PR flow: bump version, update changelog, create PR, merge 2. **Create a release branch** - Create branch: `git checkout -b release/vX.Y.Z` -3. **Gather changes since the last release** +3. **Gather changes and contributors since the last release** - Find the most recent git tag: `git describe --tags --abbrev=0` - Get commits since that tag: `git log --oneline ..HEAD --no-merges` - **Filter for end-user visible changes only** - ignore developer tooling, CI, docs, tests - Categorize changes into: Added, Changed, Fixed, Removed + - **Collect contributors:** For each PR referenced in the commits, get the author: + ```bash + gh pr view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` + - Also check for linked issue reporters (the person who filed the bug): + ```bash + gh issue view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` + - Build a deduplicated list of all contributor `@handle`s for the release 4. **Update the changelog** - Add a new section at the top of `CHANGELOG.md` with the new version and today's date - **Only include changes that affect the end-user experience** - Write clear, user-facing descriptions (not raw commit messages) + - **Credit contributors inline** (see Contributor Credits below) - Also update `docs-site/content/docs/changelog.mdx` if it exists - If there are no user-facing changes, ask the user if they still want to release @@ -73,3 +83,25 @@ If the script fails, run `say "cmux release failed"`. - Test additions or fixes - Internal refactoring with no user-visible effect - Dependency updates (unless they fix a user-facing bug) + +## Contributor Credits + +Credit the people who made each release happen. This builds community and encourages contributions. + +**Per-entry attribution** — append contributor credit after each changelog bullet: +- For code contributions (PR author): `— thanks @user!` +- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!` +- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline + +**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release: +```markdown +### Thanks to N contributors! + +- [@user1](https://github.com/user1) +- [@user2](https://github.com/user2) +``` +- List all contributors alphabetically by GitHub handle (including core team) +- Link each handle to their GitHub profile +- Include everyone: PR authors, issue reporters, anyone whose work is in the release + +**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles. diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 9ef7c00f..9903627d 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -11,16 +11,26 @@ Prepare a new release for cmux. This command updates the changelog, bumps the ve 2. **Create a release branch** - Create branch: `git checkout -b release/vX.Y.Z` -3. **Gather changes since the last release** +3. **Gather changes and contributors since the last release** - Find the most recent git tag: `git describe --tags --abbrev=0` - Get commits since that tag: `git log --oneline ..HEAD --no-merges` - **Filter for end-user visible changes only** - ignore developer tooling, CI, docs, tests - Categorize changes into: Added, Changed, Fixed, Removed + - **Collect contributors:** For each PR referenced in the commits, get the author: + ```bash + gh pr view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` + - Also check for linked issue reporters (the person who filed the bug): + ```bash + gh issue view --repo manaflow-ai/cmux --json author --jq '.author.login' + ``` + - Build a deduplicated list of all contributor `@handle`s for the release 4. **Update the changelog** - Add a new section at the top of `CHANGELOG.md` with the new version and today's date - **Only include changes that affect the end-user experience** - things users will see, feel, or interact with - Write clear, user-facing descriptions (not raw commit messages) + - **Credit contributors inline** (see Contributor Credits below) - Also update `docs-site/content/docs/changelog.mdx` with the same content - If there are no user-facing changes, ask the user if they still want to release @@ -89,18 +99,47 @@ Prepare a new release for cmux. This command updates the changelog, bumps the ve - Focus on what the user experiences, not how it was implemented - Link to issues/PRs if relevant +## Contributor Credits + +Credit the people who made each release happen. This builds community and encourages contributions. + +**Per-entry attribution** — append contributor credit after each changelog bullet: +- For code contributions (PR author): `— thanks @user!` +- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!` +- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline + +**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release: +```markdown +### Thanks to N contributors! + +- [@user1](https://github.com/user1) +- [@user2](https://github.com/user2) +``` +- List all contributors alphabetically by GitHub handle (including core team) +- Link each handle to their GitHub profile +- Include everyone: PR authors, issue reporters, anyone whose work is in the release + +**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles. + ## Example Changelog Entry ```markdown ## [0.13.0] - 2025-01-30 ### Added -- New keyboard shortcut for quick tab switching +- New keyboard shortcut for quick tab switching ([#42](https://github.com/manaflow-ai/cmux/pull/42)) — thanks @contributor! ### Fixed -- Memory leak when closing split panes -- Notification badges not clearing properly +- Memory leak when closing split panes ([#38](https://github.com/manaflow-ai/cmux/pull/38)) — thanks @fixer! +- Notification badges not clearing properly ([#35](https://github.com/manaflow-ai/cmux/pull/35)) — thanks @reporter for the report! ### Changed -- Improved terminal rendering performance +- Improved terminal rendering performance ([#40](https://github.com/manaflow-ai/cmux/pull/40)) + +### Thanks to 4 contributors! + +- [@contributor](https://github.com/contributor) +- [@fixer](https://github.com/fixer) +- [@lawrencechen](https://github.com/lawrencechen) +- [@reporter](https://github.com/reporter) ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e7bb8bc..cd3dc3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,15 @@ on: pull_request: jobs: + workflow-guard-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate self-hosted runner guards + run: ./tests/test_ci_self_hosted_guard.sh + web-typecheck: runs-on: ubuntu-latest defaults: @@ -26,6 +35,8 @@ jobs: run: bun tsc --noEmit ui-tests: + # Never run self-hosted jobs for fork pull requests. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted concurrency: group: self-hosted-build diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 965d69e1..a8ebeea4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -190,6 +190,12 @@ jobs: fi /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$APP_PLIST" + # Use an immutable DMG filename in appcast URLs so old appcasts keep + # pointing at matching archives while nightly assets roll forward. + NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" + echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" + echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV" + # Embed commit SHA for bug reports /usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$APP_PLIST" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$APP_PLIST" @@ -198,6 +204,7 @@ jobs: echo "Nightly bundle ID: com.cmuxterm.app.nightly" echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" echo "Nightly build number: ${NIGHTLY_BUILD}" + echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}" echo "Commit SHA: ${SHORT_SHA}" - name: Import signing cert @@ -283,6 +290,23 @@ jobs: xcrun stapler staple "$DMG_RELEASE" xcrun stapler validate "$DMG_RELEASE" + # Keep a stable filename for humans and an immutable filename used + # by appcast URLs to prevent signature/asset mismatch races. + cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE" + + - name: Upload dSYMs to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: manaflow + SENTRY_PROJECT: cmuxterm-macos + run: | + if [ -z "$SENTRY_AUTH_TOKEN" ]; then + echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload" + exit 0 + fi + brew install getsentry/tools/sentry-cli || true + sentry-cli debug-files upload --include-sources build/Build/Products/Release/ + - name: Generate Sparkle appcast (nightly) env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} @@ -291,7 +315,7 @@ jobs: echo "Missing SPARKLE_PRIVATE_KEY secret" >&2 exit 1 fi - ./scripts/sparkle_generate_appcast.sh cmux-nightly-macos.dmg nightly appcast.xml + ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml - name: Move nightly tag to built commit run: | @@ -315,6 +339,7 @@ jobs: [Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) files: | + cmux-nightly-macos-${{ github.run_id }}*.dmg cmux-nightly-macos.dmg appcast.xml overwrite_files: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cbcb8dd..9063de75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,63 @@ jobs: with: submodules: recursive + - name: Guard immutable release assets + id: guard_release_assets + uses: actions/github-script@v7 + with: + script: | + const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard'); + const tag = context.ref.replace('refs/tags/', ''); + core.setOutput('skip_all', 'false'); + core.setOutput('skip_upload', 'false'); + core.setOutput('release_state', 'clear'); + try { + const release = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag, + }); + const existingAssetNames = (release.data.assets || []).map((asset) => asset.name); + const { + conflicts, + missingImmutableAssets, + guardState, + hasPartialConflict, + shouldSkipBuildAndUpload, + } = evaluateReleaseAssetGuard({ existingAssetNames }); + + core.setOutput('release_state', guardState); + + if (hasPartialConflict) { + core.setFailed( + `Release ${tag} has a partial immutable asset state. Existing immutable assets: ` + + `${conflicts.join(', ')}. Missing immutable assets: ${missingImmutableAssets.join(', ')}. ` + + 'Resolve release assets manually before rerunning.' + ); + return; + } + + if (shouldSkipBuildAndUpload) { + core.notice( + `Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` + + 'Skipping build, notarization, and upload to preserve existing signed artifacts.' + ); + core.setOutput('skip_all', 'true'); + core.setOutput('skip_upload', 'true'); + return; + } + + core.notice(`Release ${tag} exists but has no immutable release assets yet; continuing.`); + } catch (error) { + if (error.status === 404) { + core.notice(`Release ${tag} does not exist yet; safe to build and publish assets.`); + return; + } + throw error; + } + - name: Select Xcode + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | set -euo pipefail if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then @@ -41,15 +97,18 @@ jobs: xcrun --sdk macosx --show-sdk-path - name: Install build deps + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | brew update brew install zig npm install --global create-dmg - name: Download Metal Toolchain + if: steps.guard_release_assets.outputs.skip_all != 'true' run: xcodebuild -downloadComponent MetalToolchain - name: Build GhosttyKit.xcframework + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | cd ghostty zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast @@ -58,11 +117,13 @@ jobs: cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework - name: Clear SPM cache + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | rm -rf ~/Library/Caches/org.swift.swiftpm rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - name: Configure SwiftPM cache + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | set -euo pipefail CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}" @@ -71,6 +132,7 @@ jobs: echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV" - name: Derive Sparkle public key from private key + if: steps.guard_release_assets.outputs.skip_all != 'true' env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -83,10 +145,12 @@ jobs: echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" - name: Build app (Release) + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build - name: Inject Sparkle keys into Info.plist + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true @@ -100,6 +164,7 @@ jobs: /usr/libexec/PlistBuddy -c "Print :SUFeedURL" "$APP_PLIST" - name: Import signing cert + if: steps.guard_release_assets.outputs.skip_all != 'true' env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -123,6 +188,7 @@ jobs: security list-keychains -d user -s build.keychain - name: Codesign app + if: steps.guard_release_assets.outputs.skip_all != 'true' env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | @@ -140,6 +206,7 @@ jobs: /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" - name: Notarize app + if: steps.guard_release_assets.outputs.skip_all != 'true' env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} @@ -183,7 +250,22 @@ jobs: xcrun stapler staple "$DMG_RELEASE" xcrun stapler validate "$DMG_RELEASE" + - name: Upload dSYMs to Sentry + if: steps.guard_release_assets.outputs.skip_all != 'true' + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: manaflow + SENTRY_PROJECT: cmuxterm-macos + run: | + if [ -z "$SENTRY_AUTH_TOKEN" ]; then + echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload" + exit 0 + fi + brew install getsentry/tools/sentry-cli || true + sentry-cli debug-files upload --include-sources build/Build/Products/Release/ + - name: Generate Sparkle appcast + if: steps.guard_release_assets.outputs.skip_all != 'true' env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -194,12 +276,14 @@ jobs: ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml - name: Upload release asset + if: steps.guard_release_assets.outputs.skip_upload != 'true' uses: softprops/action-gh-release@v2 with: files: | cmux-macos.dmg appcast.xml generate_release_notes: true + overwrite_files: false - name: Cleanup keychain if: always() diff --git a/.gitignore b/.gitignore index ced54ef5..071e93da 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ zig-out/ # Node node_modules/ +.next/ # Test outputs tests/visual_output/ diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/CHANGELOG.md b/CHANGELOG.md index 320506ea..dc79f8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,62 @@ All notable changes to cmux are documented here. +## [0.60.0] - 2026-02-21 + +### Added +- Tab context menu with rename, close, unread, and workspace actions ([#225](https://github.com/manaflow-ai/cmux/pull/225)) +- Cmd+Shift+T reopens closed browser panels ([#253](https://github.com/manaflow-ai/cmux/pull/253)) +- Vertical sidebar branch layout setting showing git branch and directory per pane +- JavaScript alert/confirm/prompt dialogs in browser panel ([#237](https://github.com/manaflow-ai/cmux/pull/237)) +- File drag-and-drop and file input in browser panel ([#214](https://github.com/manaflow-ai/cmux/pull/214)) +- tmux-compatible command set with matrix tests ([#221](https://github.com/manaflow-ai/cmux/pull/221)) +- Pane resize divider control via CLI ([#223](https://github.com/manaflow-ai/cmux/pull/223)) +- Production read-screen capture APIs ([#219](https://github.com/manaflow-ai/cmux/pull/219)) +- Notification rings on terminal panes ([#132](https://github.com/manaflow-ai/cmux/pull/132)) +- Claude Code integration enabled by default ([#247](https://github.com/manaflow-ai/cmux/pull/247)) +- HTTP host allowlist for embedded browser with save and proceed flow ([#206](https://github.com/manaflow-ai/cmux/pull/206), [#203](https://github.com/manaflow-ai/cmux/pull/203)) +- Setting to disable workspace auto-reorder on notification ([#215](https://github.com/manaflow-ai/cmux/issues/205)) +- Browser panel mouse back/forward buttons and middle-click close ([#139](https://github.com/manaflow-ai/cmux/pull/139)) +- Browser DevTools shortcut wiring and persistence ([#117](https://github.com/manaflow-ai/cmux/pull/117)) +- CJK IME input support for Korean, Chinese, and Japanese ([#125](https://github.com/manaflow-ai/cmux/pull/125)) +- `--help` flag on CLI subcommands ([#128](https://github.com/manaflow-ai/cmux/pull/128)) +- `--command` flag for `new-workspace` CLI command ([#121](https://github.com/manaflow-ai/cmux/pull/121)) +- `rename-tab` socket command ([#260](https://github.com/manaflow-ai/cmux/pull/260)) +- Remap-aware bonsplit tooltips and browser split shortcuts ([#200](https://github.com/manaflow-ai/cmux/pull/200)) + +### Fixed +- IME preedit anchor sizing ([#266](https://github.com/manaflow-ai/cmux/pull/266)) +- Cmd+Shift+T focus against deferred stale callbacks ([#267](https://github.com/manaflow-ai/cmux/pull/267)) +- Unknown Bonsplit tab context actions causing crash ([#264](https://github.com/manaflow-ai/cmux/pull/264)) +- Socket CLI commands stealing macOS app focus ([#260](https://github.com/manaflow-ai/cmux/pull/260)) +- CLI unix socket lag from main-thread blocking ([#259](https://github.com/manaflow-ai/cmux/pull/259)) +- Main-thread notification cascade causing hangs ([#232](https://github.com/manaflow-ai/cmux/pull/232)) +- Favicon out-of-sync during back/forward navigation ([#233](https://github.com/manaflow-ai/cmux/pull/233)) +- Stale sidebar git branch after closing a split +- Browser download UX and crash path ([#235](https://github.com/manaflow-ai/cmux/pull/235)) +- Browser reopen focus across workspace switches ([#257](https://github.com/manaflow-ai/cmux/pull/257)) +- Mark Tab as Unread no-op on focused tab ([#249](https://github.com/manaflow-ai/cmux/pull/249)) +- Split dividers disappearing in tiny panes ([#250](https://github.com/manaflow-ai/cmux/pull/250)) +- Flaky browser download activity accounting ([#246](https://github.com/manaflow-ai/cmux/pull/246)) +- Drag overlay routing and terminal overlay regressions ([#218](https://github.com/manaflow-ai/cmux/pull/218)) +- Initial bonsplit split animation flicker +- Window top inset on new window creation ([#224](https://github.com/manaflow-ai/cmux/pull/224)) +- Cmd+Enter being routed as browser reload ([#213](https://github.com/manaflow-ai/cmux/pull/213)) +- Child-exit close for last-terminal workspaces ([#254](https://github.com/manaflow-ai/cmux/pull/254)) +- Sidebar resizer hitbox and cursor across portals ([#255](https://github.com/manaflow-ai/cmux/pull/255)) +- Workspace-scoped tab action resolution +- IDN host allowlist normalization +- `setup.sh` cache rebuild and stale lock timeout ([#217](https://github.com/manaflow-ai/cmux/pull/217)) +- Inconsistent Tab/Workspace terminology in settings and menus ([#187](https://github.com/manaflow-ai/cmux/pull/187)) + +### Changed +- CLI workspace commands now run off the main thread for better responsiveness ([#270](https://github.com/manaflow-ai/cmux/pull/270)) +- Remove border below titlebar ([#242](https://github.com/manaflow-ai/cmux/pull/242)) +- Slimmer browser omnibar with button hover/press states ([#271](https://github.com/manaflow-ai/cmux/pull/271)) +- Browser under-page background refreshes on theme updates ([#272](https://github.com/manaflow-ai/cmux/pull/272)) +- Command shortcut hints scoped to active window ([#226](https://github.com/manaflow-ai/cmux/pull/226)) +- Nightly and release assets are now immutable (no accidental overwrite) ([#268](https://github.com/manaflow-ai/cmux/pull/268), [#269](https://github.com/manaflow-ai/cmux/pull/269)) + ## [0.59.0] - 2026-02-19 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index beb24aa0..c7617e40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,8 +93,25 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - **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`). - Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag. +- **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn. - **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`. +## Socket command threading policy + +- Do not use `DispatchQueue.main.sync` for high-frequency socket telemetry commands (`report_*`, `ports_kick`, status/progress/log metadata updates). +- For telemetry hot paths: + - Parse and validate arguments off-main. + - Dedupe/coalesce off-main first. + - Schedule minimal UI/model mutation with `DispatchQueue.main.async` only when needed. +- Commands that directly manipulate AppKit/Ghostty UI state (focus/select/open/close/send key/input, list/current queries requiring exact synchronous snapshot) are allowed to run on main actor. +- If adding a new socket command, default to off-main handling; require an explicit reason in code comments when main-thread execution is necessary. + +## Socket focus policy + +- Socket/CLI commands must not steal macOS app focus (no app activation/window raising side effects). +- 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 Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`: diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7f675483..dc05ce47 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; + A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; @@ -54,11 +55,13 @@ A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; + A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; + D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; @@ -71,11 +74,14 @@ D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; - F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; - F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; - F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; - /* End PBXBuildFile section */ + F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; + F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; + F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; + F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; + F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; + F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; + /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ A5001020 /* Embed Frameworks */ = { @@ -96,6 +102,7 @@ files = ( B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */, + D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, ); name = "Copy CLI"; runOnlyForDeploymentPostprocessing = 0; @@ -144,6 +151,7 @@ A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; + A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; @@ -177,11 +185,13 @@ A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; + A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; }; + D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/open"; sourceTree = SOURCE_ROOT; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -190,14 +200,17 @@ B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; - D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; - D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; - E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; - F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; - F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; - F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; - /* End PBXFileReference section */ + D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; + D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; + E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; + F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; + F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; + F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; + F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; + /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ A5001030 /* Frameworks */ = { @@ -319,6 +332,7 @@ A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, + A5001600 /* SentryHelper.swift */, A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -345,6 +359,7 @@ A5001219 /* WindowToolbarController.swift */, A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, + A5001611 /* SessionPersistence.swift */, ); path = Sources; sourceTree = ""; @@ -395,17 +410,20 @@ path = cmuxUITests; sourceTree = ""; }; - F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { - isa = PBXGroup; - children = ( - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, - F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, - F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, - F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, - ); - path = cmuxTests; - sourceTree = ""; - }; + F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { + isa = PBXGroup; + children = ( + F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, + F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, + F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, + F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, + ); + path = cmuxTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -548,6 +566,7 @@ A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, + A5001601 /* SentryHelper.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, @@ -574,6 +593,7 @@ A5001209 /* WindowToolbarController.swift in Sources */, A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, + A5001610 /* SessionPersistence.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -594,18 +614,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F1000005A1B2C3D4E5F60718 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, - F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, - F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, - F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B9000006A1B2C3D4E5F60719 /* Sources */ = { + F1000005A1B2C3D4E5F60718 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, + F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, + F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, + F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, + F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, + F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B9000006A1B2C3D4E5F60719 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -702,7 +725,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 71; + CURRENT_PROJECT_VERSION = 72; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -711,7 +734,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.59.0; + MARKETING_VERSION = 0.60.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -741,7 +764,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 71; + CURRENT_PROJECT_VERSION = 72; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -750,7 +773,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.59.0; + MARKETING_VERSION = 0.60.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -804,10 +827,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 71; + CURRENT_PROJECT_VERSION = 72; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.59.0; + MARKETING_VERSION = 0.60.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -821,10 +844,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 71; + CURRENT_PROJECT_VERSION = 72; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.59.0; + MARKETING_VERSION = 0.60.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -838,10 +861,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 71; + CURRENT_PROJECT_VERSION = 72; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.59.0; + MARKETING_VERSION = 0.60.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -857,10 +880,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 71; + CURRENT_PROJECT_VERSION = 72; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.59.0; + MARKETING_VERSION = 0.60.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/README.md b/README.md index 14cecfab..9122c289 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta | ⌃ ⌘ ] | Next workspace | | ⌃ ⌘ [ | Previous workspace | | ⌘ ⇧ W | Close workspace | +| ⌘ ⇧ R | Rename workspace | | ⌘ B | Toggle sidebar | ### Surfaces @@ -193,9 +194,19 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed. +## Star History + + + + + + Star History Chart + + + ## Community -- [Discord](https://discord.com/invite/QRxkhZgY) +- [Discord](https://discord.gg/xsgFEVrWCZ) - [GitHub](https://github.com/manaflow-ai/cmux) - [X / Twitter](https://twitter.com/manaflowai) - [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) diff --git a/Resources/bin/open b/Resources/bin/open new file mode 100755 index 00000000..9c81ea54 --- /dev/null +++ b/Resources/bin/open @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +# cmux open wrapper - routes HTTP(S) URLs to cmux's in-app browser +# +# When running inside a cmux terminal (CMUX_SOCKET_PATH is set), this wrapper +# intercepts `open https://...` invocations and opens them in cmux's built-in +# browser within the same workspace. All other arguments pass through to +# /usr/bin/open unchanged. + +SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}" +DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}" +PYTHON3_BIN="${CMUX_OPEN_WRAPPER_PYTHON3:-}" + +if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then + SYSTEM_OPEN_BIN="/usr/bin/open" +fi + +if [[ ! -x "$DEFAULTS_BIN" ]]; then + DEFAULTS_BIN="/usr/bin/defaults" +fi + +if [[ -n "$PYTHON3_BIN" ]]; then + if [[ ! -x "$PYTHON3_BIN" ]]; then + PYTHON3_BIN="" + fi +elif command -v python3 >/dev/null 2>&1; then + PYTHON3_BIN="$(command -v python3)" +fi + +settings_domain="${CMUX_BUNDLE_ID:-}" +whitelist_raw="" +whitelist_patterns=() + +system_open() { + exec "$SYSTEM_OPEN_BIN" "$@" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +to_lower_ascii() { + # Bash 3.2-compatible lowercase conversion. + LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +normalize_boolean() { + to_lower_ascii "$(trim "$1")" +} + +is_false_setting() { + local normalized + normalized="$(normalize_boolean "$1")" + case "$normalized" in + 0|false|no|off) + return 0 + ;; + esac + return 1 +} + +canonicalize_idn_host() { + local value="$1" + [[ -z "$PYTHON3_BIN" ]] && { + printf '%s' "$value" + return 0 + } + + local canonicalized + canonicalized="$("$PYTHON3_BIN" - "$value" <<'PY' 2>/dev/null || true +import sys + +host = sys.argv[1].strip().rstrip(".") +if not host: + raise SystemExit(1) + +labels = host.split(".") +if any(not label for label in labels): + raise SystemExit(1) + +try: + canonical = ".".join(label.encode("idna").decode("ascii") for label in labels) +except Exception: + raise SystemExit(1) + +sys.stdout.write(canonical.lower()) +PY +)" + if [[ -n "$canonicalized" ]]; then + printf '%s' "$canonicalized" + return 0 + fi + printf '%s' "$value" +} + +is_http_url() { + local value="$1" + case "$value" in + [Hh][Tt][Tt][Pp]://*|[Hh][Tt][Tt][Pp][Ss]://*) + return 0 + ;; + esac + return 1 +} + +normalize_host() { + local value + value="$(trim "$1")" + value="$(to_lower_ascii "$value")" + [[ -z "$value" ]] && return 1 + + if [[ "$value" == *"://"* ]]; then + value="${value#*://}" + fi + + value="${value%%/*}" + value="${value%%\?*}" + value="${value%%\#*}" + + if [[ "$value" == *"@"* ]]; then + value="${value##*@}" + fi + + if [[ "$value" == \[* ]]; then + value="${value#\[}" + value="${value%%\]*}" + elif [[ "$value" == *:* ]]; then + local colons="${value//[^:]}" + if [[ ${#colons} -eq 1 ]] && [[ "$value" =~ :[0-9]+$ ]]; then + value="${value%:*}" + fi + fi + + while [[ "$value" == .* ]]; do + value="${value#.}" + done + while [[ "$value" == *. ]]; do + value="${value%.}" + done + + [[ -z "$value" ]] && return 1 + value="$(canonicalize_idn_host "$value")" + printf '%s' "$value" +} + +normalize_whitelist_pattern() { + local value + value="$(trim "$1")" + value="$(to_lower_ascii "$value")" + [[ -z "$value" ]] && return 1 + + if [[ "$value" == \*.* ]]; then + local suffix + suffix="$(normalize_host "${value#*.}")" || return 1 + printf '*.%s' "$suffix" + return 0 + fi + + normalize_host "$value" +} + +host_matches_pattern() { + local host="$1" + local pattern="$2" + + if [[ "$pattern" == \*.* ]]; then + local suffix="${pattern#*.}" + [[ "$host" == "$suffix" ]] && return 0 + [[ "$host" == *".$suffix" ]] && return 0 + return 1 + fi + + [[ "$host" == "$pattern" ]] +} + +host_matches_whitelist() { + local url="$1" + if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then + return 0 + fi + + local host + host="$(normalize_host "$url")" || return 1 + for pattern in "${whitelist_patterns[@]}"; do + if host_matches_pattern "$host" "$pattern"; then + return 0 + fi + done + return 1 +} + +load_whitelist_patterns() { + local raw="$1" + local line + while IFS= read -r line || [[ -n "$line" ]]; do + local normalized + normalized="$(normalize_whitelist_pattern "$line")" || continue + whitelist_patterns+=("$normalized") + done <<< "$raw" +} + +# Pass through immediately if not in a cmux terminal. +if [[ -z "$CMUX_SOCKET_PATH" ]]; then + system_open "$@" +fi + +# No arguments → pass through. +if [[ $# -eq 0 ]]; then + system_open "$@" +fi + +# Scan for flags that indicate explicit user intent → pass through. +# Also collect non-flag arguments (potential URLs/files). +passthrough=false +urls=() +for arg in "$@"; do + case "$arg" in + -a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr) + passthrough=true + break + ;; + -*) + # Unknown flag → be conservative, pass through + passthrough=true + break + ;; + *) + if is_http_url "$arg"; then + urls+=("$arg") + else + # Non-URL, non-flag argument (file path, etc.) → pass through all + passthrough=true + break + fi + ;; + esac +done + +if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then + system_open "$@" +fi + +# Respect the same settings used for terminal link clicks. +if [[ -n "$settings_domain" ]]; then + open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserInterceptTerminalOpenCommandInCmuxBrowser 2>/dev/null || true)" + if [[ -z "$open_in_cmux" ]]; then + # Backward compatibility for installs that predate the dedicated open-wrapper toggle. + open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" + fi + if is_false_setting "$open_in_cmux"; then + system_open "$@" + fi + + whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" + if [[ -n "$whitelist_raw" ]]; then + load_whitelist_patterns "$whitelist_raw" + fi +fi + +# Find cmux CLI (same directory as this script). +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +CMUX_CLI="$SELF_DIR/cmux" + +if [[ ! -x "$CMUX_CLI" ]]; then + system_open "$@" +fi + +# Open each URL in cmux's in-app browser; track failures individually. +failed_urls=() +for url in "${urls[@]}"; do + if ! host_matches_whitelist "$url"; then + failed_urls+=("$url") + continue + fi + "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") +done + +# Fall back to system open only for URLs that failed. +if [[ ${#failed_urls[@]} -gt 0 ]]; then + system_open "${failed_urls[@]}" +fi diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 1e110f91..070b33e9 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -23,6 +23,18 @@ _cmux_send() { fi } +_cmux_restore_scrollback_once() { + local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" + [[ -n "$path" ]] || return 0 + unset CMUX_RESTORE_SCROLLBACK_FILE + + if [[ -r "$path" ]]; then + /bin/cat -- "$path" 2>/dev/null || true + /bin/rm -f -- "$path" >/dev/null 2>&1 || true + fi +} +_cmux_restore_scrollback_once + # Throttle heavy work to avoid prompt latency. _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}" _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" @@ -107,9 +119,9 @@ _cmux_prompt_command() { local first first=$(git status --porcelain -uno 2>/dev/null | head -1) [[ -n "$first" ]] && dirty_opt="--status=dirty" - _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID" + _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" else - _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID" + _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" fi } >/dev/null 2>&1 & _CMUX_GIT_JOB_PID=$! diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 3b5d00cc..3121788f 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -24,6 +24,18 @@ _cmux_send() { fi } +_cmux_restore_scrollback_once() { + local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" + [[ -n "$path" ]] || return 0 + unset CMUX_RESTORE_SCROLLBACK_FILE + + if [[ -r "$path" ]]; then + /bin/cat -- "$path" 2>/dev/null || true + /bin/rm -f -- "$path" >/dev/null 2>&1 || true + fi +} +_cmux_restore_scrollback_once + # Throttle heavy work to avoid prompt latency. typeset -g _CMUX_PWD_LAST_PWD="" typeset -g _CMUX_GIT_LAST_PWD="" @@ -240,9 +252,9 @@ _cmux_precmd() { local first first=$(git status --porcelain -uno 2>/dev/null | head -1) [[ -n "$first" ]] && dirty_opt="--status=dirty" - _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID" + _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" else - _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID" + _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" fi } >/dev/null 2>&1 &! _CMUX_GIT_JOB_PID=$! diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 408123f4..337ad9f3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -36,6 +36,188 @@ enum FinderServicePathResolver { } } +enum TerminalDirectoryOpenTarget: String, CaseIterable { + case vscode + case cursor + case windsurf + case antigravity + case finder + case terminal + case iterm2 + case ghostty + case warp + case xcode + case androidStudio + case zed + + struct DetectionEnvironment { + let homeDirectoryPath: String + let fileExistsAtPath: (String) -> Bool + + static let live = DetectionEnvironment( + homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) } + ) + } + + static var commandPaletteShortcutTargets: [Self] { + Array(allCases) + } + + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { + Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) + } + + static let cachedLiveAvailableTargets: Set = availableTargets(in: .live) + + var commandPaletteCommandId: String { + "palette.terminalOpenDirectory.\(rawValue)" + } + + var commandPaletteTitle: String { + switch self { + case .vscode: + return "Open Current Directory in VS Code" + case .cursor: + return "Open Current Directory in Cursor" + case .windsurf: + return "Open Current Directory in Windsurf" + case .antigravity: + return "Open Current Directory in Antigravity" + case .finder: + return "Open Current Directory in Finder" + case .terminal: + return "Open Current Directory in Terminal" + case .iterm2: + return "Open Current Directory in iTerm2" + case .ghostty: + return "Open Current Directory in Ghostty" + case .warp: + return "Open Current Directory in Warp" + case .xcode: + return "Open Current Directory in Xcode" + case .androidStudio: + return "Open Current Directory in Android Studio" + case .zed: + return "Open Current Directory in Zed" + } + } + + var commandPaletteKeywords: [String] { + let common = ["terminal", "directory", "open", "ide"] + switch self { + case .vscode: + return common + ["vs", "code", "visual", "studio"] + case .cursor: + return common + ["cursor"] + case .windsurf: + return common + ["windsurf"] + case .antigravity: + return common + ["antigravity"] + case .finder: + return common + ["finder", "file", "manager", "reveal"] + case .terminal: + return common + ["terminal", "shell"] + case .iterm2: + return common + ["iterm", "iterm2", "terminal", "shell"] + case .ghostty: + return common + ["ghostty", "terminal", "shell"] + case .warp: + return common + ["warp", "terminal", "shell"] + case .xcode: + return common + ["xcode", "apple"] + case .androidStudio: + return common + ["android", "studio"] + case .zed: + return common + ["zed"] + } + } + + func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { + applicationPath(in: environment) != nil + } + + func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { + guard let path = applicationPath(in: environment) else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private func applicationPath(in environment: DetectionEnvironment) -> String? { + for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { + return path + } + return nil + } + + private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { + let globalPrefix = "/Applications/" + let userPrefix = "\(environment.homeDirectoryPath)/Applications/" + var expanded: [String] = [] + + for candidate in applicationBundlePathCandidates { + expanded.append(candidate) + if candidate.hasPrefix(globalPrefix) { + let suffix = String(candidate.dropFirst(globalPrefix.count)) + expanded.append(userPrefix + suffix) + } + } + + return uniquePreservingOrder(expanded) + } + + private var applicationBundlePathCandidates: [String] { + switch self { + case .vscode: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .cursor: + return [ + "/Applications/Cursor.app", + "/Applications/Cursor Preview.app", + "/Applications/Cursor Nightly.app", + ] + case .windsurf: + return ["/Applications/Windsurf.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .finder: + return ["/System/Library/CoreServices/Finder.app"] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .iterm2: + return [ + "/Applications/iTerm.app", + "/Applications/iTerm2.app", + ] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .warp: + return ["/Applications/Warp.app"] + case .xcode: + return ["/Applications/Xcode.app"] + case .androidStudio: + return ["/Applications/Android Studio.app"] + case .zed: + return [ + "/Applications/Zed.app", + "/Applications/Zed Preview.app", + "/Applications/Zed Nightly.app", + ] + } + } + + private func uniquePreservingOrder(_ paths: [String]) -> [String] { + var seen: Set = [] + var deduped: [String] = [] + for path in paths where seen.insert(path).inserted { + deduped.append(path) + } + return deduped + } +} + enum WorkspaceShortcutMapper { /// Maps Cmd+digit workspace shortcuts to a zero-based workspace index. /// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace. @@ -64,16 +246,23 @@ enum WorkspaceShortcutMapper { } } +private extension NSScreen { + var cmuxDisplayID: UInt32? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let value = deviceDescription[key] as? NSNumber else { return nil } + return value.uint32Value + } +} + func browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: Bool, flags: NSEvent.ModifierFlags, chars: String ) -> Int? { guard hasFocusedAddressBar else { return nil } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags == [.control] else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return nil } if chars == "n" { return 1 } if chars == "p" { return -1 } return nil @@ -85,9 +274,7 @@ func browserOmnibarSelectionDeltaForArrowNavigation( keyCode: UInt16 ) -> Int? { guard hasFocusedAddressBar else { return nil } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) guard normalizedFlags == [] else { return nil } switch keyCode { case 125: return 1 @@ -96,11 +283,88 @@ func browserOmnibarSelectionDeltaForArrowNavigation( } } +func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) +} + func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags == [] || normalizedFlags == [.shift] +} + +func shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowser: Bool +) -> Bool { + guard firstResponderIsBrowser else { return false } + return keyCode == 36 || keyCode == 76 +} + +func commandPaletteSelectionDeltaForKeyboardNavigation( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Int? { let normalizedFlags = flags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) - return normalizedFlags == [] || normalizedFlags == [.shift] + let normalizedChars = chars.lowercased() + + if normalizedFlags == [] { + switch keyCode { + case 125: return 1 // Down arrow + case 126: return -1 // Up arrow + default: break + } + } + + if normalizedFlags == [.control] { + // Control modifiers can surface as either printable chars or ASCII control chars. + if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N + if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P + if keyCode == 38 || normalizedChars == "j" || normalizedChars == "\u{0a}" { return 1 } // Ctrl+J + if keyCode == 40 || normalizedChars == "k" || normalizedChars == "\u{0b}" { return -1 } // Ctrl+K + } + + return nil +} + +func shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: Bool, + normalizedFlags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + guard isCommandPaletteVisible else { return false } + guard normalizedFlags.contains(.command) else { return false } + + let normalizedChars = chars.lowercased() + + if normalizedFlags == [.command] { + if normalizedChars == "a" + || normalizedChars == "c" + || normalizedChars == "v" + || normalizedChars == "x" + || normalizedChars == "z" + || normalizedChars == "y" { + return false + } + + switch keyCode { + case 51, 117, 123, 124: + return false + default: + break + } + } + + if normalizedFlags == [.command, .shift], normalizedChars == "z" { + return false + } + + return true } enum BrowserZoomShortcutAction: Equatable { @@ -109,6 +373,22 @@ enum BrowserZoomShortcutAction: Equatable { case reset } +struct CommandPaletteDebugResultRow { + let commandId: String + let title: String + let shortcutHint: String? + let trailingLabel: String? + let score: Int +} + +struct CommandPaletteDebugSnapshot { + let query: String + let mode: String + let results: [CommandPaletteDebugResultRow] + + static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) +} + func browserZoomShortcutAction( flags: NSEvent.ModifierFlags, chars: String, @@ -118,26 +398,163 @@ func browserZoomShortcutAction( .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) let key = chars.lowercased() + let hasCommand = normalizedFlags.contains(.command) + let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) - if normalizedFlags == [.command] { - if key == "=" || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus - return .zoomIn - } - if key == "-" || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus - return .zoomOut - } - if key == "0" || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 - return .reset - } - } + guard hasOnlyCommandAndOptionalShift else { return nil } - if normalizedFlags == [.command, .shift] && (key == "=" || key == "+" || keyCode == 24 || keyCode == 69) { + if key == "=" || key == "+" || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus return .zoomIn } + if key == "-" || key == "_" || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus + return .zoomOut + } + + if key == "0" || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 + return .reset + } + return nil } +func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: Bool, + hostedSize: CGSize, + hostedHiddenInHierarchy: Bool, + hostedAttachedToWindow: Bool +) -> Bool { + guard firstResponderIsWindow else { return false } + let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 + return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow +} + +func shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: Bool, + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + guard firstResponderIsGhostty else { return false } + return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil +} + +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView + } + + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView + } + + var current = responder.nextResponder + while let next = current { + if let ghosttyView = next as? GhosttyNSView { + return ghosttyView + } + if let view = next as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + current = next.nextResponder + } + + return nil +} + +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView + } + + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView + } + current = candidate.superview + } + + return nil +} + +#if DEBUG +func browserZoomShortcutTraceCandidate( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + guard normalizedFlags.contains(.command) else { return false } + + let key = chars.lowercased() + if key == "=" || key == "+" || key == "-" || key == "_" || key == "0" { + return true + } + switch keyCode { + case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys + return true + default: + return false + } +} + +func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + var parts: [String] = [] + if normalizedFlags.contains(.command) { parts.append("Cmd") } + if normalizedFlags.contains(.shift) { parts.append("Shift") } + if normalizedFlags.contains(.option) { parts.append("Opt") } + if normalizedFlags.contains(.control) { parts.append("Ctrl") } + return parts.isEmpty ? "none" : parts.joined(separator: "+") +} + +func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { + guard let action else { return "none" } + switch action { + case .zoomIn: return "zoomIn" + case .zoomOut: return "zoomOut" + case .reset: return "reset" + } +} +#endif + +func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { + var candidate = hitView + while let view = candidate { + if view is DraggableFolderNSView { + return true + } + candidate = view.superview + } + return false +} + +func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + window.isMovable, + let contentView = window.contentView else { + return false + } + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { static var shared: AppDelegate? @@ -186,15 +603,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + struct SessionDisplayGeometry { + let displayID: UInt32? + let frame: CGRect + let visibleFrame: CGRect + } + + private struct PersistedWindowGeometry: Codable, Sendable { + let frame: SessionRectSnapshot + let display: SessionDisplaySnapshot? + } + + private static let persistedWindowGeometryDefaultsKey = "cmux.session.lastWindowGeometry.v1" + weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? weak var sidebarState: SidebarState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? private var workspaceObserver: NSObjectProtocol? + private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? private var shortcutDefaultsObserver: NSObjectProtocol? + private var splitButtonTooltipRefreshScheduled = false private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -222,6 +654,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } method_exchangeImplementations(originalMethod, swizzledMethod) }() + private static let didInstallWindowFirstResponderSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) + let swizzledSelector = #selector(NSWindow.cmux_makeFirstResponder(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallWindowSendEventSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.sendEvent(_:)) + let swizzledSelector = #selector(NSWindow.cmux_sendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() #if DEBUG private var didSetupJumpUnreadUITest = false @@ -230,6 +682,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupGotoSplitUITest = false private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false + // Keep debug-only windows alive when tests intentionally inject key mismatches. + private var debugDetachedContextWindows: [NSWindow] = [] private func childExitKeyboardProbePath() -> String? { let env = ProcessInfo.processInfo.environment @@ -271,11 +725,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] private var mainWindowControllers: [MainWindowController] = [] + private var startupSessionSnapshot: AppSessionSnapshot? + private var didPrepareStartupSessionSnapshot = false + private var didAttemptStartupSessionRestore = false + private var isApplyingStartupSessionRestore = false + private var sessionAutosaveTimer: DispatchSourceTimer? + private var didHandleExplicitOpenIntentAtStartup = false + private var isTerminatingApp = false + private var didInstallLifecycleSnapshotObservers = false + private var didDisableSuddenTermination = false + private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] + private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] + private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] var updateViewModel: UpdateViewModel { updateController.viewModel } +#if DEBUG + private func pointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let window = context.window ?? windowForMainWindowId(context.windowId) + let windowNumber = window?.windowNumber ?? -1 + let key = window?.isKeyWindow == true ? 1 : 0 + let main = window?.isMainWindow == true ? 1 : 0 + let visible = window?.isVisible == true ? 1 : 0 + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" + return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" + } + + private func summarizeAllContextsForWorkspaceRouting() -> String { + guard !mainWindowContexts.isEmpty else { return "" } + return mainWindowContexts.values + .map { summarizeContextForWorkspaceRouting($0) } + .joined(separator: " | ") + } + + private func logWorkspaceCreationRouting( + phase: String, + source: String, + reason: String, + event: NSEvent?, + chosenContext: MainWindowContext?, + workspaceId: UUID? = nil, + workingDirectory: String? = nil + ) { + let eventWindowNumber = event?.window?.windowNumber ?? -1 + let eventNumber = event?.windowNumber ?? -1 + let eventChars = event?.charactersIgnoringModifiers ?? "" + let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" + let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 + let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 + let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" + let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" + FocusLogStore.shared.append( + "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" + ) + } +#endif + override init() { super.init() Self.shared = self @@ -310,6 +823,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent options.debug = false #endif options.sendDefaultPii = true + + // Performance tracing (10% of transactions) + options.tracesSampleRate = 0.1 + // App hang timeout (default is 2s, be explicit) + options.appHangTimeoutInterval = 2.0 + // Attach stack traces to all events + options.attachStacktrace = true + // Capture failed HTTP requests + options.enableCaptureFailedRequests = true } if !isRunningUnderXCTest { @@ -341,7 +863,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installMainWindowKeyObserver() refreshGhosttyGotoSplitShortcuts() installGhosttyConfigObserver() - installWindowKeyEquivalentSwizzle() + installWindowResponderSwizzles() installBrowserAddressBarFocusObservers() installShortcutMonitor() installShortcutDefaultsObserver() @@ -415,6 +937,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif func applicationDidBecomeActive(_ notification: Notification) { + sentryBreadcrumb("app.didBecomeActive", category: "lifecycle", data: [ + "tabCount": tabManager?.tabs.count ?? 0 + ]) let env = ProcessInfo.processInfo.environment if !isRunningUnderXCTest(env) { PostHogAnalytics.shared.trackDailyActive(reason: "didBecomeActive") @@ -432,17 +957,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + return .terminateNow + } + func applicationWillTerminate(_ notification: Notification) { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + stopSessionAutosaveTimer() TerminalController.shared.stop() BrowserHistoryStore.shared.flushPendingSaves() PostHogAnalytics.shared.flush() notificationStore?.clearAll() + enableSuddenTerminationIfNeeded() + } + + func applicationWillResignActive(_ notification: Notification) { + guard !isTerminatingApp else { return } + _ = saveSessionSnapshot(includeScrollback: false) + } + + func persistSessionForUpdateRelaunch() { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { self.tabManager = tabManager self.notificationStore = notificationStore self.sidebarState = sidebarState + disableSuddenTerminationIfNeeded() + installLifecycleSnapshotObserversIfNeeded() + prepareStartupSessionSnapshotIfNeeded() + startSessionAutosaveTimerIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() @@ -455,7 +1004,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if isRunningUnderXCTest(env) { let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) ?? SocketControlSettings.defaultMode.rawValue - let userMode = SocketControlMode(rawValue: raw) ?? SocketControlSettings.defaultMode + let userMode = SocketControlSettings.migrateMode(raw) let mode = SocketControlSettings.effectiveMode(userMode: userMode) if mode != .off { TerminalController.shared.start( @@ -468,6 +1017,669 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } + private func prepareStartupSessionSnapshotIfNeeded() { + guard !didPrepareStartupSessionSnapshot else { return } + didPrepareStartupSessionSnapshot = true + guard SessionRestorePolicy.shouldAttemptRestore() else { return } + startupSessionSnapshot = SessionPersistenceStore.load() + } + + private func persistedWindowGeometry( + defaults: UserDefaults = .standard + ) -> PersistedWindowGeometry? { + guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { + return nil + } + return try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data) + } + + private func persistWindowGeometry( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot?, + defaults: UserDefaults = .standard + ) { + guard let frame else { return } + let payload = PersistedWindowGeometry(frame: frame, display: display) + guard let data = try? JSONEncoder().encode(payload) else { return } + defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) + } + + private func persistWindowGeometry(from window: NSWindow?) { + guard let window else { return } + persistWindowGeometry( + frame: SessionRectSnapshot(window.frame), + display: displaySnapshot(for: window) + ) + } + + private func currentDisplayGeometries() -> ( + available: [SessionDisplayGeometry], + fallback: SessionDisplayGeometry? + ) { + let available = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + return (available, fallback) + } + + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { + guard !didAttemptStartupSessionRestore else { return } + didAttemptStartupSessionRestore = true + guard !didHandleExplicitOpenIntentAtStartup else { return } + guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } + + let startupSnapshot = startupSessionSnapshot + let primaryWindowSnapshot = startupSnapshot?.windows.first + if let primaryWindowSnapshot { + isApplyingStartupSessionRestore = true +#if DEBUG + dlog( + "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + + "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + + "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" + ) +#endif + applySessionWindowSnapshot( + primaryWindowSnapshot, + to: primaryContext, + window: primaryWindow + ) + } else { + let displays = currentDisplayGeometries() + let fallbackGeometry = persistedWindowGeometry() + if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackGeometry?.frame, + fallbackDisplaySnapshot: fallbackGeometry?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) { + primaryWindow.setFrame(restoredFrame, display: true) + } + } + + if let startupSnapshot { + let additionalWindows = Array(startupSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) +#if DEBUG + for (index, windowSnapshot) in additionalWindows.enumerated() { + dlog( + "session.restore.enqueueAdditional idx=\(index + 1) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + ) + } +#endif + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + } + self.completeStartupSessionRestore() + } + } else { + completeStartupSessionRestore() + } + } + } + + private func completeStartupSessionRestore() { + startupSessionSnapshot = nil + isApplyingStartupSessionRestore = false + _ = saveSessionSnapshot(includeScrollback: false) + } + + private func applySessionWindowSnapshot( + _ snapshot: SessionWindowSnapshot, + to context: MainWindowContext, + window: NSWindow? + ) { +#if DEBUG + dlog( + "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + + "liveWin=\(window?.windowNumber ?? -1) " + + "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + + "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" + ) +#endif + context.tabManager.restoreSessionSnapshot(snapshot.tabManager) + context.sidebarState.isVisible = snapshot.sidebar.isVisible + context.sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + ) + context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection + + if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { + window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif + } + } + + private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { + let displays = currentDisplayGeometries() + return Self.resolvedWindowFrame( + from: snapshot?.frame, + display: snapshot?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) + } + + nonisolated static func resolvedStartupPrimaryWindowFrame( + primarySnapshot: SessionWindowSnapshot?, + fallbackFrame: SessionRectSnapshot?, + fallbackDisplaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + if let primary = resolvedWindowFrame( + from: primarySnapshot?.frame, + display: primarySnapshot?.display, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay + ) { + return primary + } + + return resolvedWindowFrame( + from: fallbackFrame, + display: fallbackDisplaySnapshot, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay + ) + } + + nonisolated static func resolvedWindowFrame( + from frameSnapshot: SessionRectSnapshot?, + display displaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + guard let frameSnapshot else { return nil } + let frame = frameSnapshot.cgRect + guard frame.width.isFinite, + frame.height.isFinite, + frame.origin.x.isFinite, + frame.origin.y.isFinite else { + return nil + } + + let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) + let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) + guard frame.width >= minWidth, + frame.height >= minHeight else { + return nil + } + + guard !availableDisplays.isEmpty else { return frame } + + if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { + if shouldPreserveExactFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay + ) { + return frame + } + return resolvedWindowFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { + return clampFrame( + frame, + within: intersectingDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + guard let fallbackDisplay else { return frame } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func resolvedWindowFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + if targetDisplay.visibleFrame.intersects(frame) { + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func display( + for snapshot: SessionDisplaySnapshot?, + in displays: [SessionDisplayGeometry] + ) -> SessionDisplayGeometry? { + guard let snapshot else { return nil } + if let displayID = snapshot.displayID, + let exact = displays.first(where: { $0.displayID == displayID }) { + return exact + } + + guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { + return nil + } + + let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in + (display, intersectionArea(referenceRect, display.visibleFrame)) + } + if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { + return bestOverlap.display + } + + let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) + return displays.min { lhs, rhs in + let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) + let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) + return lhsDistance < rhsDistance + } + } + + private nonisolated static func remappedFrame( + _ frame: CGRect, + from sourceRect: CGRect, + to targetRect: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let source = sourceRect.standardized + let target = targetRect.standardized + guard source.width.isFinite, + source.height.isFinite, + source.width > 1, + source.height > 1, + target.width.isFinite, + target.height.isFinite, + target.width > 0, + target.height > 0 else { + return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) + } + + let relativeX = (frame.minX - source.minX) / source.width + let relativeY = (frame.minY - source.minY) / source.height + let relativeWidth = frame.width / source.width + let relativeHeight = frame.height / source.height + + let remapped = CGRect( + x: target.minX + (relativeX * target.width), + y: target.minY + (relativeY * target.height), + width: target.width * relativeWidth, + height: target.height * relativeHeight + ) + return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) + } + + private nonisolated static func centeredFrame( + _ frame: CGRect, + in visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let centered = CGRect( + x: visibleFrame.midX - (frame.width / 2), + y: visibleFrame.midY - (frame.height / 2), + width: frame.width, + height: frame.height + ) + return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) + } + + private nonisolated static func clampFrame( + _ frame: CGRect, + within visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + guard visibleFrame.width.isFinite, + visibleFrame.height.isFinite, + visibleFrame.width > 0, + visibleFrame.height > 0 else { + return frame + } + + let maxWidth = max(visibleFrame.width, 1) + let maxHeight = max(visibleFrame.height, 1) + let widthFloor = min(minWidth, maxWidth) + let heightFloor = min(minHeight, maxHeight) + + let width = min(max(frame.width, widthFloor), maxWidth) + let height = min(max(frame.height, heightFloor), maxHeight) + let maxX = visibleFrame.maxX - width + let maxY = visibleFrame.maxY - height + let x = min(max(frame.minX, visibleFrame.minX), maxX) + let y = min(max(frame.minY, visibleFrame.minY), maxY) + + return CGRect(x: x, y: y, width: width, height: height) + } + + private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return max(0, intersection.width) * max(0, intersection.height) + } + + private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { + let dx = rect.midX - point.x + let dy = rect.midY - point.y + return (dx * dx) + (dy * dy) + } + + private nonisolated static func shouldPreserveExactFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry + ) -> Bool { + guard let displaySnapshot else { return false } + guard let snapshotDisplayID = displaySnapshot.displayID, + let targetDisplayID = targetDisplay.displayID, + snapshotDisplayID == targetDisplayID else { + return false + } + + let visibleMatches = displaySnapshot.visibleFrame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) + } ?? false + let frameMatches = displaySnapshot.frame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.frame) + } ?? false + guard visibleMatches || frameMatches else { return false } + + return frame.width.isFinite + && frame.height.isFinite + && frame.origin.x.isFinite + && frame.origin.y.isFinite + } + + private nonisolated static func rectApproximatelyEqual( + _ lhs: CGRect, + _ rhs: CGRect, + tolerance: CGFloat = 1 + ) -> Bool { + let lhsStd = lhs.standardized + let rhsStd = rhs.standardized + return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance + && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance + && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance + && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance + } + + private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { + guard let window else { return nil } + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) + guard let screen else { return nil } + + return SessionDisplaySnapshot( + displayID: screen.cmuxDisplayID, + frame: SessionRectSnapshot(screen.frame), + visibleFrame: SessionRectSnapshot(screen.visibleFrame) + ) + } + + private func startSessionAutosaveTimerIfNeeded() { + guard sessionAutosaveTimer == nil else { return } + let env = ProcessInfo.processInfo.environment + guard !isRunningUnderXCTest(env) else { return } + + let timer = DispatchSource.makeTimerSource(queue: .main) + let interval = SessionPersistencePolicy.autosaveInterval + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + _ = self?.saveSessionSnapshot(includeScrollback: false) + } + sessionAutosaveTimer = timer + timer.resume() + } + + private func stopSessionAutosaveTimer() { + sessionAutosaveTimer?.cancel() + sessionAutosaveTimer = nil + } + + private func installLifecycleSnapshotObserversIfNeeded() { + guard !didInstallLifecycleSnapshotObservers else { return } + didInstallLifecycleSnapshotObservers = true + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let powerOffObserver = workspaceCenter.addObserver( + forName: NSWorkspace.willPowerOffNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.isTerminatingApp = true + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } + } + lifecycleSnapshotObservers.append(powerOffObserver) + + let sessionResignObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + if self.isTerminatingApp { + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } else { + _ = self.saveSessionSnapshot(includeScrollback: false) + } + } + } + lifecycleSnapshotObservers.append(sessionResignObserver) + } + + private func disableSuddenTerminationIfNeeded() { + guard !didDisableSuddenTermination else { return } + ProcessInfo.processInfo.disableSuddenTermination() + didDisableSuddenTermination = true + } + + private func enableSuddenTerminationIfNeeded() { + guard didDisableSuddenTermination else { return } + ProcessInfo.processInfo.enableSuddenTermination() + didDisableSuddenTermination = false + } + + @discardableResult + private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + if Self.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, + includeScrollback: includeScrollback + ) { +#if DEBUG + dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") +#endif + return false + } + guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + if removeWhenEmpty { + SessionPersistenceStore.removeSnapshot() + } + return false + } + if let primaryWindow = snapshot.windows.first { + persistWindowGeometry( + frame: primaryWindow.frame, + display: primaryWindow.display + ) + } +#if DEBUG + debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) +#endif + return SessionPersistenceStore.save(snapshot) + } + + nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } + + nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: Bool + ) -> Bool { + !isTerminatingApp + } + + nonisolated static func shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: Bool, + includeScrollback: Bool + ) -> Bool { + isApplyingStartupSessionRestore && !includeScrollback + } + + private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) + let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) + let lhsIsKey = lhsWindow?.isKeyWindow ?? false + let rhsIsKey = rhsWindow?.isKeyWindow ?? false + if lhsIsKey != rhsIsKey { + return lhsIsKey && !rhsIsKey + } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + guard !contexts.isEmpty else { return nil } + + let windows: [SessionWindowSnapshot] = contexts + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) + .map { context in + let window = context.window ?? windowForMainWindowId(context.windowId) + return SessionWindowSnapshot( + frame: window.map { SessionRectSnapshot($0.frame) }, + display: displaySnapshot(for: window), + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), + sidebar: SessionSidebarSnapshot( + isVisible: context.sidebarState.isVisible, + selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), + width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) + ) + ) + } + + guard !windows.isEmpty else { return nil } + return AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: windows + ) + } + +#if DEBUG + private func debugLogSessionSaveSnapshot( + _ snapshot: AppSessionSnapshot, + includeScrollback: Bool + ) { + dlog( + "session.save includeScrollback=\(includeScrollback ? 1 : 0) " + + "windows=\(snapshot.windows.count)" + ) + for (index, windowSnapshot) in snapshot.windows.enumerated() { + let workspaceCount = windowSnapshot.tabManager.workspaces.count + let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil" + dlog( + "session.save.window idx=\(index) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + + "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" + ) + } + } + + private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + + "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" + } + + private func debugNSRectDescription(_ rect: NSRect?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(Double(rect.origin.x))) " + + "y=\(debugSessionNumber(Double(rect.origin.y))) " + + "w=\(debugSessionNumber(Double(rect.size.width))) " + + "h=\(debugSessionNumber(Double(rect.size.height)))" + } + + private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { + guard let display else { return "nil" } + let displayIdText = display.displayID.map(String.init) ?? "nil" + return "id=\(displayIdText) " + + "frame={\(debugSessionRectDescription(display.frame))} " + + "visible={\(debugSessionRectDescription(display.visibleFrame))}" + } + + private func debugSessionNumber(_ value: Double) -> String { + String(format: "%.1f", value) + } +#endif + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -477,9 +1689,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarState: SidebarState, sidebarSelectionState: SidebarSelectionState ) { + tabManager.window = window + let key = ObjectIdentifier(window) + #if DEBUG + let priorManagerToken = debugManagerToken(self.tabManager) + #endif if let existing = mainWindowContexts[key] { existing.window = window + } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + existing.window = window + reindexMainWindowContextIfNeeded(existing, for: window) } else { mainWindowContexts[key] = MainWindowContext( windowId: windowId, @@ -497,10 +1717,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.unregisterMainWindow(closing) } } + commandPaletteVisibilityByWindowId[windowId] = false + commandPaletteSelectionByWindowId[windowId] = 0 + commandPaletteSnapshotByWindowId[windowId] = .empty +#if DEBUG + dlog( + "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + ) +#endif if window.isKeyWindow { setActiveMainWindow(window) } + + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if !isTerminatingApp { + _ = saveSessionSnapshot(includeScrollback: false) + } } struct MainWindowSummary { @@ -511,6 +1744,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let selectedWorkspaceId: UUID? } + struct WindowMoveTarget: Identifiable { + let windowId: UUID + let label: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: UUID { windowId } + } + + struct WorkspaceMoveTarget: Identifiable { + let windowId: UUID + let workspaceId: UUID + let windowLabel: String + let workspaceTitle: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } + var label: String { + isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" + } + } + func listMainWindowSummaries() -> [MainWindowSummary] { let contexts = Array(mainWindowContexts.values) return contexts.map { ctx in @@ -525,6 +1781,406 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + return orderedSummaries.compactMap { summary in + guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } + let label = labels[summary.windowId] ?? "Window" + return WindowMoveTarget( + windowId: summary.windowId, + label: label, + tabManager: manager, + isCurrentWindow: summary.windowId == referenceWindowId + ) + } + } + + func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + + var targets: [WorkspaceMoveTarget] = [] + targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in + partial + summary.workspaceCount + }) + + for summary in orderedSummaries { + guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } + let windowLabel = labels[summary.windowId] ?? "Window" + let isCurrentWindow = summary.windowId == referenceWindowId + for workspace in manager.tabs { + if workspace.id == excludingWorkspaceId { + continue + } + targets.append( + WorkspaceMoveTarget( + windowId: summary.windowId, + workspaceId: workspace.id, + windowLabel: windowLabel, + workspaceTitle: workspaceDisplayName(workspace), + tabManager: manager, + isCurrentWindow: isCurrentWindow + ) + ) + } + } + + return targets + } + + @discardableResult + func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { + guard let sourceManager = tabManagerFor(tabId: workspaceId), + let destinationManager = tabManagerFor(windowId: windowId) else { + return false + } + + if sourceManager === destinationManager { + if focus { + destinationManager.focusTab(workspaceId, suppressFlash: true) + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) + } + return true + } + + guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } + destinationManager.attachWorkspace(workspace, select: focus) + + if focus { + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) + } + return true + } + + @discardableResult + func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { + let windowId = createMainWindow() + guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } + let bootstrapWorkspaceId = destinationManager.tabs.first?.id + + guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { + _ = closeMainWindow(windowId: windowId) + return nil + } + + // Remove the bootstrap workspace from the new window once the moved workspace arrives. + if let bootstrapWorkspaceId, + bootstrapWorkspaceId != workspaceId, + let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + destinationManager.tabs.count > 1 { + destinationManager.closeWorkspace(bootstrapWorkspace) + } + return windowId + } + + func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + let bonsplitTabId = TabID(uuid: tabId) + for context in mainWindowContexts.values { + for workspace in context.tabManager.tabs { + if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { + return (context.windowId, workspace.id, panelId, context.tabManager) + } + } + } + return nil + } + + @discardableResult + func moveSurface( + panelId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true + ) -> Bool { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + let splitLabel = splitTarget.map { split in + "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" + } ?? "none" + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" + ) +#endif + guard let source = locateSurface(surfaceId: panelId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } +#if DEBUG + dlog( + "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + + "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" + ) +#endif + + let resolvedTargetPane = targetPane.flatMap { pane in + destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first + + guard let resolvedTargetPane else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } + + if destinationWorkspace.id == sourceWorkspace.id { + if let splitTarget { + guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId), + sourceWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: sourceTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } + if focus { + source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) + } +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return true + } + + let moved = sourceWorkspace.moveSurface( + panelId: panelId, + toPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved + } + + let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime +#endif + + guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + let detachMs = elapsedMs(since: detachStart) + let attachStart = ProcessInfo.processInfo.systemUptime +#endif + guard destinationWorkspace.attachDetachedSurface( + detached, + inPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) != nil else { + rollbackDetachedSurface( + detached, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + + "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + let attachMs = elapsedMs(since: attachStart) + var splitMs = "0.00" +#endif + + if let splitTarget { +#if DEBUG + let splitStart = ProcessInfo.processInfo.systemUptime +#endif + guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), + destinationWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: movedTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { + if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { + rollbackDetachedSurface( + detachedFromDestination, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) + } +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + + "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + splitMs = elapsedMs(since: splitStart) +#endif + } + +#if DEBUG + let cleanupStart = ProcessInfo.processInfo.systemUptime +#endif + cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: sourceWorkspace, + sourceManager: source.tabManager, + sourceWindowId: source.windowId + ) +#if DEBUG + let cleanupMs = elapsedMs(since: cleanupStart) + let focusStart = ProcessInfo.processInfo.systemUptime +#endif + + if focus { + let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil + if let destinationWindowId { + _ = focusMainWindow(windowId: destinationWindowId) + } + destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) + if let destinationWindowId { + reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: destinationWindowId, + sourceWindowId: source.windowId, + destinationWorkspaceId: targetWorkspaceId, + destinationPanelId: panelId, + destinationManager: destinationManager + ) + } + } +#if DEBUG + let focusMs = elapsedMs(since: focusStart) + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + + "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + + "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + + return true + } + + @discardableResult + func moveBonsplitTab( + tabId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true + ) -> Bool { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" + ) +#endif + guard let located = locateBonsplitSurface(tabId: tabId) else { +#if DEBUG + dlog( + "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + + "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + dlog( + "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" + ) +#endif + let moved = moveSurface( + panelId: located.panelId, + toWorkspace: targetWorkspaceId, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: focus, + focusWindow: focusWindow + ) +#if DEBUG + dlog( + "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved + } + func tabManagerFor(windowId: UUID) -> TabManager? { mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager } @@ -533,6 +2189,111 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId } + func mainWindow(for windowId: UUID) -> NSWindow? { + windowForMainWindowId(windowId) + } + + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteVisibilityByWindowId[windowId] = visible + } + + func isCommandPaletteVisible(windowId: UUID) -> Bool { + commandPaletteVisibilityByWindowId[windowId] ?? false + } + + func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSelectionByWindowId[windowId] = max(0, index) + } + + func commandPaletteSelectionIndex(windowId: UUID) -> Int { + commandPaletteSelectionByWindowId[windowId] ?? 0 + } + + func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSnapshotByWindowId[windowId] = snapshot + } + + func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { + commandPaletteSnapshotByWindowId[windowId] ?? .empty + } + + func isCommandPaletteVisible(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + return commandPaletteVisibilityByWindowId[windowId] ?? false + } + + func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: NSWindow, + responder: NSResponder? + ) -> Bool { + guard isCommandPaletteVisible(for: window) else { return false } + guard let responder else { return false } + guard !isCommandPaletteResponder(responder) else { return false } + return isFocusStealingResponderWhileCommandPaletteVisible(responder) + } + + private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { + if let textView = responder as? NSTextView, textView.isFieldEditor { + if let delegateView = textView.delegate as? NSView { + return isInsideCommandPaletteOverlay(delegateView) + } + // SwiftUI can attach a non-view delegate to TextField editors. + // When command palette is visible, its search/rename editor is the + // only expected field editor inside the main window. + return true + } + if let view = responder as? NSView { + return isInsideCommandPaletteOverlay(view) + } + return false + } + + private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { + if responder is GhosttyNSView || responder is WKWebView { + return true + } + + if let textView = responder as? NSTextView, + !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView { + return isTerminalOrBrowserView(delegateView) + } + + if let view = responder as? NSView { + return isTerminalOrBrowserView(view) + } + + return false + } + + private func isTerminalOrBrowserView(_ view: NSView) -> Bool { + if view is GhosttyNSView || view is WKWebView { + return true + } + var current: NSView? = view.superview + while let candidate = current { + if candidate is GhosttyNSView || candidate is WKWebView { + return true + } + current = candidate.superview + } + return false + } + + private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return true + } + current = candidate.superview + } + return false + } + func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { for ctx in mainWindowContexts.values { for ws in ctx.tabManager.tabs { @@ -561,6 +2322,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func focusMainWindow(windowId: UUID) -> Bool { guard let window = windowForMainWindowId(windowId) else { return false } + if TerminalController.shouldSuppressSocketCommandActivation() { + if window.isMiniaturized { + window.deminiaturize(nil) + } + if TerminalController.socketCommandAllowsInAppFocusMutations() { + window.orderFront(nil) + setActiveMainWindow(window) + } + return true + } bringToFront(window) return true } @@ -571,6 +2342,103 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { + let summaries = listMainWindowSummaries() + return summaries.sorted { lhs, rhs in + let lhsIsReference = lhs.windowId == referenceWindowId + let rhsIsReference = rhs.windowId == referenceWindowId + if lhsIsReference != rhsIsReference { return lhsIsReference } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + } + + private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { + var labels: [UUID: String] = [:] + for (index, summary) in orderedSummaries.enumerated() { + if summary.windowId == referenceWindowId { + labels[summary.windowId] = "Current Window" + } else { + labels[summary.windowId] = "Window \(index + 1)" + } + } + return labels + } + + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Workspace" : trimmed + } + + private func rollbackDetachedSurface( + _ detached: Workspace.DetachedSurfaceTransfer, + to workspace: Workspace, + sourcePane: PaneID?, + sourceIndex: Int?, + focus: Bool + ) { + let rollbackPane = sourcePane.flatMap { pane in + workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first + guard let rollbackPane else { return } + _ = workspace.attachDetachedSurface( + detached, + inPane: rollbackPane, + atIndex: sourceIndex, + focus: focus + ) + } + + private func cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: Workspace, + sourceManager: TabManager, + sourceWindowId: UUID + ) { + guard sourceWorkspace.panels.isEmpty else { return } + guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } + + if sourceManager.tabs.count > 1 { + sourceManager.closeWorkspace(sourceWorkspace) + } else { + _ = closeMainWindow(windowId: sourceWindowId) + } + } + + private func reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: UUID, + sourceWindowId: UUID, + destinationWorkspaceId: UUID, + destinationPanelId: UUID, + destinationManager: TabManager + ) { + let reassert: () -> Void = { [weak self, weak destinationManager] in + guard let self, let destinationManager else { return } + guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), + workspace.panels[destinationPanelId] != nil else { + return + } + guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } + guard let keyWindow = NSApp.keyWindow, + let keyWindowId = self.mainWindowId(for: keyWindow), + keyWindowId == sourceWindowId, + keyWindow !== destinationWindow else { + return + } + + self.bringToFront(destinationWindow) + destinationManager.focusTab( + destinationWorkspaceId, + surfaceId: destinationPanelId, + suppressFlash: true + ) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) + } + private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), let window = ctx.window { @@ -580,6 +2448,307 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) } + private func mainWindowId(from window: NSWindow) -> UUID? { + guard let raw = window.identifier?.rawValue else { return nil } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix) else { return nil } + let suffix = String(raw.dropFirst(prefix.count)) + return UUID(uuidString: suffix) + } + + private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { + let desiredKey = ObjectIdentifier(window) + if mainWindowContexts[desiredKey] === context { + context.window = window + return + } + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + + if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { + context.window = window + return + } + + mainWindowContexts[desiredKey] = context + context.window = window + } + + private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { + guard isMainTerminalWindow(window) else { return nil } + + if let context = mainWindowContexts[ObjectIdentifier(window)] { + context.window = window + return context + } + + if let windowId = mainWindowId(from: window), + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context + } + + let windowNumber = window.windowNumber + if windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) + return candidateWindow?.windowNumber == windowNumber + }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context + } + + return nil + } + + private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { + guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } + let removedKeys = mainWindowContexts.compactMap { key, value in + value === removed ? key : nil + } + for key in removedKeys { + mainWindowContexts.removeValue(forKey: key) + } + return removed + } + + private func mainWindowId(for window: NSWindow) -> UUID? { + if let context = mainWindowContexts[ObjectIdentifier(window)] { + return context.windowId + } + guard let rawIdentifier = window.identifier?.rawValue, + rawIdentifier.hasPrefix("cmux.main.") else { return nil } + let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) + return UUID(uuidString: idPart) + } + + private func activeCommandPaletteWindow() -> NSWindow? { + if let keyWindow = NSApp.keyWindow, + let windowId = mainWindowId(for: keyWindow), + commandPaletteVisibilityByWindowId[windowId] == true { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, + let windowId = mainWindowId(for: mainWindow), + commandPaletteVisibilityByWindowId[windowId] == true { + return mainWindow + } + if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(visibleWindowId) + } + return nil + } + + private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let scopedWindow = mainWindowForShortcutEvent(event) { + return scopedWindow + } + return activeCommandPaletteWindow() + } + + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window, isMainTerminalWindow(window) else { return nil } + return mainWindowContexts[ObjectIdentifier(window)] + } + +#if DEBUG + private func debugManagerToken(_ manager: TabManager?) -> String { + guard let manager else { return "nil" } + return String(describing: Unmanaged.passUnretained(manager).toOpaque()) + } + + private func debugWindowToken(_ window: NSWindow?) -> String { + guard let window else { return "nil" } + let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none" + let ident = window.identifier?.rawValue ?? "nil" + let shortIdent: String + if ident.count > 120 { + shortIdent = String(ident.prefix(120)) + "..." + } else { + shortIdent = ident + } + return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" + } + + private func debugContextToken(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0 + return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)" + } + + private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String { + let activeManager = tabManager + let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil" + let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + + let contexts = mainWindowContexts.values + .map { context in + let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-" + let window = context.window ?? windowForMainWindowId(context.windowId) + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}" + } + .sorted() + .joined(separator: ",") + + let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" + let eventWindow = event?.window + return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" + } +#endif + + private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let window = event.window, isMainTerminalWindow(window) { + return window + } + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: eventWindowNumber), + isMainTerminalWindow(numberedWindow) { + return numberedWindow + } + if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { + return mainWindow + } + return nil + } + + /// Re-sync app-level active window pointers from the currently focused main terminal window. + /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. + @discardableResult + func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? { + let (context, source): (MainWindowContext?, String) = { + if let preferredWindow, + let context = contextForMainWindow(preferredWindow) { + return (context, "preferredWindow") + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return (context, "keyWindow") + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return (context, "mainWindow") + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return (activeContext, "activeManager") + } + return (mainWindowContexts.values.first, "firstContextFallback") + }() + +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) + dlog( + "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) +#endif + guard let context else { return tabManager } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } +#if DEBUG + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) +#endif + return context.tabManager + } + + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { + if let context = contextForMainWindow(event.window) { + return context + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return context + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return context + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return activeContext + } + return mainWindowContexts.values.first + } + + private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { + let preferredWindow = mainWindowForShortcutEvent(event) +#if DEBUG + dlog( + "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) +#if DEBUG + dlog( + "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + } + + @discardableResult + func toggleSidebarInActiveMainWindow() -> Bool { + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) { + setActiveMainWindow(window) + } + activeContext.sidebarState.toggle() + return true + } + if let keyContext = contextForMainWindow(NSApp.keyWindow) { + if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) { + setActiveMainWindow(window) + } + keyContext.sidebarState.toggle() + return true + } + if let mainContext = contextForMainWindow(NSApp.mainWindow) { + if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) { + setActiveMainWindow(window) + } + mainContext.sidebarState.toggle() + return true + } + if let fallbackContext = mainWindowContexts.values.first { + if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) { + setActiveMainWindow(window) + } + fallbackContext.sidebarState.toggle() + return true + } + if let sidebarState { + sidebarState.toggle() + return true + } + return false + } + + func sidebarVisibility(windowId: UUID) -> Bool? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible + } + @objc func openNewMainWindow(_ sender: Any?) { _ = createMainWindow() } @@ -610,6 +2779,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent target: ServiceOpenTarget, error: AutoreleasingUnsafeMutablePointer ) { + didHandleExplicitOpenIntentAtStartup = true + if !didAttemptStartupSessionRestore { + startupSessionSnapshot = nil + didAttemptStartupSessionRestore = true + } + let pathURLs = servicePathURLs(from: pasteboard) guard !pathURLs.isEmpty else { error.pointee = Self.serviceErrorNoPath @@ -661,26 +2836,253 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func openWorkspaceFromService(workingDirectory: String) { - if let context = preferredMainWindowContextForServiceWorkspace(), - let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) - _ = context.tabManager.addWorkspace(workingDirectory: workingDirectory) + if addWorkspaceInPreferredMainWindow( + workingDirectory: workingDirectory, + shouldBringToFront: true, + debugSource: "service.openTab" + ) != nil { return } _ = createMainWindow(initialWorkingDirectory: workingDirectory) } - private func preferredMainWindowContextForServiceWorkspace() -> MainWindowContext? { + @discardableResult + func addWorkspaceInPreferredMainWindow( + workingDirectory: String? = nil, + shouldBringToFront: Bool = false, + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> UUID? { + #if DEBUG + logWorkspaceCreationRouting( + phase: "request", + source: debugSource, + reason: "add_workspace", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + #if DEBUG + logWorkspaceCreationRouting( + phase: "no_context", + source: debugSource, + reason: "context_selection_failed", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + return nil + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } + } + + let workspace: Workspace + if let workingDirectory { + workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) + } else { + workspace = context.tabManager.addTab(select: true) + } + #if DEBUG + logWorkspaceCreationRouting( + phase: "created", + source: debugSource, + reason: "workspace_created", + event: event, + chosenContext: context, + workspaceId: workspace.id, + workingDirectory: workingDirectory + ) + #endif + return workspace.id + } + + private func preferredMainWindowContextForWorkspaceCreation( + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { + return context + } + + // If a keyboard event identifies a specific window but that context + // can't be resolved, do not fall back to another window. + if shortcutEventHasAddressableWindow(event) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) +#endif + return nil + } + if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let context = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let context = contextForMainTerminalWindow(keyWindow) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "key_window", + event: event, + chosenContext: context + ) + #endif return context } if let mainWindow = NSApp.mainWindow, - isMainTerminalWindow(mainWindow), - let context = mainWindowContexts[ObjectIdentifier(mainWindow)] { + let context = contextForMainTerminalWindow(mainWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "main_window", + event: event, + chosenContext: context + ) + #endif + return context + } + + for window in NSApp.orderedWindows where isMainTerminalWindow(window) { + if let context = contextForMainTerminalWindow(window) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "ordered_windows", + event: event, + chosenContext: context + ) + #endif + return context + } + } + + let fallback = mainWindowContexts.values.first + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "fallback_first_context", + event: event, + chosenContext: fallback + ) +#endif + return fallback + } + + private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { + guard let event else { return false } + // NSEvent.windowNumber can be 0 for responder-chain events that are not + // actually bound to an NSWindow (notably some WebKit key paths). + return event.window != nil || event.windowNumber > 0 + } + + private func mainWindowContext( + forShortcutEvent event: NSEvent?, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + guard let event else { return nil } + + if let eventWindow = event.window, + let context = contextForMainTerminalWindow(eventWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window", + event: event, + chosenContext: context + ) + #endif + return context + } + + if event.windowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), + let context = contextForMainTerminalWindow(numberedWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number", + event: event, + chosenContext: context + ) + #endif + return context + } + + if event.windowNumber > 0, + let context = mainWindowContexts.values.first(where: { candidate in + let window = candidate.window ?? windowForMainWindowId(candidate.windowId) + return window?.windowNumber == event.windowNumber + }) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number_scan", + event: event, + chosenContext: context + ) + #endif + return context + } + + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_not_found", + event: event, + chosenContext: nil + ) + #endif + return nil + } + + private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { + return context + } + + if shortcutEventHasAddressableWindow(event) { +#if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) +#endif + return nil + } + + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { + return context + } + + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + return context + } + + if let activeManager = tabManager, + let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { return context } @@ -688,11 +3090,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @discardableResult - func createMainWindow(initialWorkingDirectory: String? = nil) -> UUID { + private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" + ) +#endif + return false + } + + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { return true } + + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } + +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + ) +#endif + return true + } + + @discardableResult + func createMainWindow( + initialWorkingDirectory: String? = nil, + sessionWindowSnapshot: SessionWindowSnapshot? = nil + ) -> UUID { let windowId = UUID() let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) - let sidebarState = SidebarState() - let sidebarSelectionState = SidebarSelectionState() + if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { + tabManager.restoreSessionSnapshot(tabManagerSnapshot) + } + + let sidebarWidth = sessionWindowSnapshot?.sidebar.width + .map(SessionPersistencePolicy.sanitizedSidebarWidth) + ?? SessionPersistencePolicy.defaultSidebarWidth + let sidebarState = SidebarState( + isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, + persistedWidth: CGFloat(sidebarWidth) + ) + let sidebarSelectionState = SidebarSelectionState( + selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs + ) let notificationStore = TerminalNotificationStore.shared let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) @@ -711,7 +3162,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = false - window.center() + window.isMovable = false + let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) + if let restoredFrame { + window.setFrame(restoredFrame, display: false) + } else { + window.center() + } window.contentView = NSHostingView(rootView: root) // Apply shared window styling. @@ -735,9 +3192,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarSelectionState: sidebarSelectionState ) installFileDropOverlay(on: window, tabManager: tabManager) - window.makeKeyAndOrderFront(nil) - setActiveMainWindow(window) - NSApp.activate(ignoringOtherApps: true) + if TerminalController.shouldSuppressSocketCommandActivation() { + window.orderFront(nil) + if TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + } + } else { + window.makeKeyAndOrderFront(nil) + setActiveMainWindow(window) + NSApp.activate(ignoringOtherApps: true) + } + if let restoredFrame { + window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif + } return windowId } @@ -746,6 +3219,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.checkForUpdates() } + @objc func applyUpdateIfAvailable(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.installUpdate() + } + + @objc func attemptUpdate(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.attemptUpdate() + } + private func setupMenuBarExtra() { let store = TerminalNotificationStore.shared menuBarExtraController = MenuBarExtraController( @@ -787,8 +3270,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func showNotificationsPopoverFromMenuBar() { let context: MainWindowContext? = { if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let keyContext = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let keyContext = contextForMainTerminalWindow(keyWindow) { return keyContext } if let first = mainWindowContexts.values.first { @@ -862,6 +3344,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG + private let debugColorWorkspaceTitlePrefix = "Debug Color - " + @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } let tab = tabManager.addTab() @@ -885,6 +3369,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sendTextWhenReady(payload, to: tab) } + @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { + guard let tabManager else { return } + + let palette = WorkspaceTabColorSettings.palette() + guard !palette.isEmpty else { return } + + var existingByTitle: [String: Workspace] = [:] + for tab in tabManager.tabs { + guard let title = tab.customTitle, + title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } + existingByTitle[title] = tab + } + + for entry in palette { + let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" + let targetTab: Workspace + if let existing = existingByTitle[title] { + targetTab = existing + } else { + targetTab = tabManager.addTab() + } + tabManager.setCustomTitle(tabId: targetTab.id, title: title) + tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) + } + } + private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) { let maxAttempts = 60 if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { @@ -1432,8 +3942,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func installWindowKeyEquivalentSwizzle() { + static func installWindowResponderSwizzlesForTesting() { + _ = didInstallWindowKeyEquivalentSwizzle + _ = didInstallWindowFirstResponderSwizzle + _ = didInstallWindowSendEventSwizzle + } + +#if DEBUG + static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { + cmuxFirstResponderGuardCurrentEventOverride = currentEvent + cmuxFirstResponderGuardHitViewOverride = hitView + } + + static func clearWindowFirstResponderGuardTesting() { + cmuxFirstResponderGuardCurrentEventOverride = nil + cmuxFirstResponderGuardHitViewOverride = nil + } +#endif + + private func installWindowResponderSwizzles() { _ = Self.didInstallWindowKeyEquivalentSwizzle + _ = Self.didInstallWindowFirstResponderSwizzle + _ = Self.didInstallWindowSendEventSwizzle } private func installShortcutMonitor() { @@ -1450,7 +3980,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") } let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")") + dlog( + "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" + ) if let probeKind = self.developerToolsShortcutProbeKind(event: event) { self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) } @@ -1479,7 +4011,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in - self?.refreshSplitButtonTooltipsAcrossWorkspaces() + self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() + } + } + + /// Coalesce shortcut-default changes and refresh on the next runloop turn to + /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. + private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { + guard !splitButtonTooltipRefreshScheduled else { return } + splitButtonTooltipRefreshScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.splitButtonTooltipRefreshScheduled = false + self.refreshSplitButtonTooltipsAcrossWorkspaces() } } @@ -1617,6 +4161,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) } + private func handleQuitShortcutWarning() -> Bool { + if !QuitWarningSettings.isEnabled() { + NSApp.terminate(nil) + return true + } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Quit cmux?" + alert.informativeText = "This will close all windows and workspaces." + alert.addButton(withTitle: "Quit") + alert.addButton(withTitle: "Cancel") + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Don't warn again for Cmd+Q" + + let response = alert.runModal() + if alert.suppressionButton?.state == .on { + QuitWarningSettings.setEnabled(false) + } + + if response == .alertFirstButtonReturn { + NSApp.terminate(nil) + } + return true + } + + func promptRenameSelectedWorkspace() -> Bool { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + NSSound.beep() + return false + } + + let alert = NSAlert() + alert.messageText = "Rename Workspace" + alert.informativeText = "Enter a custom name for this workspace." + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = "Workspace name" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return true } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + return true + } + 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. @@ -1668,9 +4268,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) + let commandPaletteVisibleInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteVisible(for: $0) + } ?? false + + if let delta = commandPaletteSelectionDeltaForKeyboardNavigation( + flags: event.modifierFlags, + chars: chars, + keyCode: event.keyCode + ), + commandPaletteVisibleInTargetWindow, + let paletteWindow = commandPaletteTargetWindow { + NotificationCenter.default.post( + name: .commandPaletteMoveSelection, + object: paletteWindow, + userInfo: ["delta": delta] + ) + return true + } + + let isCommandP = normalizedFlags == [.command] && (chars == "p" || event.keyCode == 35) + if isCommandP { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + return true + } + + let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35) + if isCommandShiftP { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + return true + } + + if shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: commandPaletteVisibleInTargetWindow, + normalizedFlags: normalizedFlags, + chars: chars, + keyCode: event.keyCode + ) { + return true + } + + if normalizedFlags == [.command], chars == "q" { + return handleQuitShortcutWarning() + } + if normalizedFlags == [.command, .shift], + (chars == "," || chars == "<" || event.keyCode == 43) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma") + return true + } + // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese // input), don't intercept key events — let them flow through to the input method. - if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView, + if let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), ghosttyView.hasMarkedText() { return false } @@ -1688,11 +4341,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + let hasEventWindowContext = shortcutEventHasAddressableWindow(event) + let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) + if hasEventWindowContext && !didSynchronizeShortcutContext { +#if DEBUG + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") +#endif + return false + } + // Keep keyboard routing deterministic after split close/reparent transitions: // before processing shortcuts, converge first responder with the focused terminal panel. if isControlD { +#if DEBUG + let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") +#endif tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() #if DEBUG + let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) #endif // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. @@ -1703,7 +4373,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // (e.g., split that doesn't properly blur the address bar). If the first responder // is a terminal surface, the address bar can't be focused. if browserAddressBarFocusedPanelId != nil, - NSApp.keyWindow?.firstResponder is GhosttyNSView { + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") #endif @@ -1735,18 +4405,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Primary UI shortcuts if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) { - sidebarState?.toggle() + _ = toggleSidebarInActiveMainWindow() return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) { +#if DEBUG + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") +#endif // Cmd+N semantics: // - If there are no main windows, create a new window. // - Otherwise, create a new workspace in the active window. - if tabManager == nil || mainWindowContexts.isEmpty { + if mainWindowContexts.isEmpty { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "no_main_windows", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) + } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "workspace_creation_returned_nil", + event: event, + chosenContext: nil + ) + #endif openNewMainWindow(nil) - } else { - tabManager?.addTab() } return true } @@ -1816,11 +4507,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameWorkspace)) { + _ = promptRenameSelectedWorkspace() + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) { + tabManager?.closeCurrentWorkspaceWithConfirmation() + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWindow)) { + guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return true + } + targetWindow.performClose(nil) + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameTab)) { + // Keep Cmd+R browser reload behavior when a browser panel is focused. + if tabManager?.focusedBrowserPanel != nil { + return false + } + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + return true + } + // Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace) if flags == [.command], let manager = tabManager, let num = Int(chars), let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) { +#if DEBUG + dlog( + "shortcut.action name=workspaceDigit digit=\(num) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + ) +#endif manager.selectTab(at: targetIndex) return true } @@ -1889,11 +4614,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Split actions: Cmd+D / Cmd+Shift+D if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) { +#if DEBUG + dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } _ = performSplitShortcut(direction: .right) return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) { +#if DEBUG + dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { + return true + } _ = performSplitShortcut(direction: .down) return true } @@ -1926,9 +4663,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Open browser: Cmd+Shift+L if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openBrowser)) { - if let panelId = tabManager?.openBrowser(insertAtEnd: true) { - focusBrowserAddressBar(panelId: panelId) - } + _ = openBrowserAndFocusAddressBar(insertAtEnd: true) return true } @@ -1977,27 +4712,130 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if let panelId = tabManager?.openBrowser(insertAtEnd: true) { - focusBrowserAddressBar(panelId: panelId) + if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { return true } } - if let action = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode), - let manager = tabManager { + #if DEBUG + logBrowserZoomShortcutTrace(stage: "probe", event: event, flags: flags, chars: chars) + #endif + let zoomAction = browserZoomShortcutAction(flags: flags, chars: chars, keyCode: event.keyCode) + #if DEBUG + logBrowserZoomShortcutTrace(stage: "match", event: event, flags: flags, chars: chars, action: zoomAction) + #endif + if let action = zoomAction, let manager = tabManager { + let handled: Bool switch action { case .zoomIn: - return manager.zoomInFocusedBrowser() + handled = manager.zoomInFocusedBrowser() case .zoomOut: - return manager.zoomOutFocusedBrowser() + handled = manager.zoomOutFocusedBrowser() case .reset: - return manager.resetZoomFocusedBrowser() + handled = manager.resetZoomFocusedBrowser() } + #if DEBUG + logBrowserZoomShortcutTrace( + stage: "dispatch", + event: event, + flags: flags, + chars: chars, + action: action, + handled: handled + ) + #endif + return handled } + #if DEBUG + if zoomAction != nil, tabManager == nil { + logBrowserZoomShortcutTrace( + stage: "dispatch.noManager", + event: event, + flags: flags, + chars: chars, + action: zoomAction, + handled: false + ) + } + #endif return false } + private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + return false + } + + let hostedView = terminalPanel.hostedView + let hostedSize = hostedView.bounds.size + let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor + let hostedAttachedToWindow = hostedView.window != nil + let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow + + let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: firstResponderIsWindow, + hostedSize: hostedSize, + hostedHiddenInHierarchy: hostedHiddenInHierarchy, + hostedAttachedToWindow: hostedAttachedToWindow + ) + guard shouldSuppress else { return false } + + tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + +#if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + + "attached=\(hostedAttachedToWindow ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" + ) +#endif + return true + } + +#if DEBUG + private func logBrowserZoomShortcutTrace( + stage: String, + event: NSEvent, + flags: NSEvent.ModifierFlags, + chars: String, + action: BrowserZoomShortcutAction? = nil, + handled: Bool? = nil + ) { + guard browserZoomShortcutTraceCandidate(flags: flags, chars: chars, keyCode: event.keyCode) else { + return + } + + let keyWindow = NSApp.keyWindow + let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let panel = tabManager?.focusedBrowserPanel + let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" + let panelZoom = panel?.webView.pageZoom ?? -1 + var line = + "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + + "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + + "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + + "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" + if let handled { + line += " handled=\(handled ? 1 : 0)" + } + dlog(line) + } +#endif + @discardableResult private func focusBrowserAddressBar(panelId: UUID) -> Bool { guard let tabManager, @@ -2010,21 +4848,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + @discardableResult + func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { + guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else { + return nil + } + _ = focusBrowserAddressBar(panelId: panelId) + return panelId + } + private func focusBrowserAddressBar(in panel: BrowserPanel) { _ = panel.requestAddressBarFocus() browserAddressBarFocusedPanelId = panel.id NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) } + func focusedBrowserAddressBarPanelId() -> UUID? { + browserAddressBarFocusedPanelId + } + + @discardableResult + func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { + focusBrowserAddressBar(panelId: panelId) + } + private func shouldBypassAppShortcutForFocusedBrowserAddressBar( flags: NSEvent.ModifierFlags, chars: String ) -> Bool { guard browserAddressBarFocusedPanelId != nil else { return false } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags == [.control] else { return false } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return false } return chars == "n" || chars == "p" } @@ -2210,6 +5065,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + let directionLabel: String switch direction { case .left: directionLabel = "left" @@ -2275,6 +5132,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } _ = focusBrowserAddressBar(panelId: panelId) return true @@ -2294,6 +5153,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func debugHandleCustomShortcut(event: NSEvent) -> Bool { handleCustomShortcut(event: event) } + + // Test hook: remap a window context under a detached window key so direct + // ObjectIdentifier(window) lookups fail and fallback logic is exercised. + @discardableResult + func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = context.window ?? windowForMainWindowId(windowId) else { + return false + } + + let detachedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + debugDetachedContextWindows.append(detachedWindow) + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + mainWindowContexts[ObjectIdentifier(detachedWindow)] = context + context.window = window + return true + } #endif private func findButton(in view: NSView, titled title: String) -> NSButton? { @@ -2621,6 +5508,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { [weak self] notification in guard let self else { return } guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() if self.browserAddressBarFocusedPanelId == panelId { self.browserAddressBarFocusedPanelId = nil self.stopBrowserOmnibarSelectionRepeat() @@ -2636,17 +5524,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func setActiveMainWindow(_ window: NSWindow) { - guard isMainTerminalWindow(window) else { return } - guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return } + guard let context = contextForMainTerminalWindow(window) else { return } +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) +#endif tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState TerminalController.shared.setActiveTabManager(context.tabManager) +#if DEBUG + dlog( + "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" + ) +#endif } private func unregisterMainWindow(_ window: NSWindow) { - let key = ObjectIdentifier(window) - guard let removed = mainWindowContexts.removeValue(forKey: key) else { return } + // Keep geometry available as a fallback even if the full session snapshot + // is removed when the last window closes. + persistWindowGeometry(from: window) + guard let removed = unregisterMainWindowContext(for: window) else { return } + commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) // Avoid stale notifications that can no longer be opened once the owning window is gone. if let store = notificationStore { @@ -2659,8 +5559,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Repoint "active" pointers to any remaining main terminal window. let nextContext: MainWindowContext? = { if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let ctx = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { return ctx } return mainWindowContexts.values.first @@ -2678,9 +5577,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent TerminalController.shared.setActiveTabManager(nil) } } + + // During app termination we already persisted a full snapshot (with scrollback) + // in applicationShouldTerminate/applicationWillTerminate. Saving again here would + // overwrite it as windows tear down one-by-one, dropping closed windows and replay. + if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { + _ = saveSessionSnapshot( + includeScrollback: false, + removeWhenEmpty: Self.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: isTerminatingApp + ) + ) + } } private func isMainTerminalWindow(_ window: NSWindow) -> Bool { + if mainWindowContexts[ObjectIdentifier(window)] != nil { + return true + } guard let raw = window.identifier?.rawValue else { return false } return raw == "cmux.main" || raw.hasPrefix("cmux.main.") } @@ -3093,6 +6007,9 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { stateHintItem.title = snapshot.stateHintTitle + applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) + applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) + jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications markAllReadItem.isEnabled = snapshot.hasUnreadNotifications clearAllItem.isEnabled = snapshot.hasNotifications @@ -3107,6 +6024,16 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { } } + private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { + guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { for item in notificationItems { menu.removeItem(item) @@ -3574,7 +6501,106 @@ enum MenuBarIconRenderer { } +#if DEBUG +private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent? +private var cmuxFirstResponderGuardHitViewOverride: NSView? +#endif +private var cmuxBrowserReturnForwardingDepth = 0 + private extension NSWindow { + @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: self, + responder: responder + ) == true { +#if DEBUG + dlog( + "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + if let responder, + let webView = Self.cmuxOwningWebView(for: responder), + !webView.allowsFirstResponderAcquisitionEffective { + let currentEvent = Self.cmuxCurrentEvent(for: self) + let pointerInitiatedFocus = Self.cmuxShouldAllowPointerInitiatedWebViewFocus( + window: self, + webView: webView, + event: currentEvent + ) + if pointerInitiatedFocus { +#if DEBUG + dlog( + "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + } else { +#if DEBUG + dlog( + "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + return false + } + } +#if DEBUG + if let responder, + let webView = Self.cmuxOwningWebView(for: responder) { + dlog( + "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + ) + } +#endif + return cmux_makeFirstResponder(responder) + } + + @objc func cmux_sendEvent(_ event: NSEvent) { + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), + let contentView = self.contentView else { + cmux_sendEvent(event) + return + } + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + let previousMovableState = isMovable + if previousMovableState { + isMovable = false + } + + #if DEBUG + let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" + dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") + #endif + + cmux_sendEvent(event) + + if previousMovableState { + isMovable = previousMovableState + } + + #if DEBUG + dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + #endif + } + @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" @@ -3596,7 +6622,9 @@ private extension NSWindow { // Command shortcuts when the terminal is focused — the local event monitor // (handleCustomShortcut) already handles app-level shortcuts, and anything // remaining should be menu items. - if let ghosttyView = self.firstResponder as? GhosttyNSView { + let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + let firstResponderWebView = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0) } + if let ghosttyView = firstResponderGhosttyView { // If the IME is composing, don't intercept key events — let them flow // through normal AppKit event dispatch so the input method can process them. if ghosttyView.hasMarkedText() { @@ -3611,6 +6639,47 @@ private extension NSWindow { #endif return result } + + // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when + // the terminal is focused. Otherwise our browser menu shortcuts can + // consume the event even when no browser panel is focused. + if shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode + ) { + ghosttyView.keyDown(with: event) +#if DEBUG + dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") +#endif + return true + } + } + + // Web forms rely on Return/Enter flowing through keyDown. If the original + // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches + // WebKit. Route Return/Enter directly to the current first responder and + // mark handled to avoid the AppKit alert sound path. + if shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil + ) { + // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. + // On re-entry, fall back to normal dispatch to avoid an infinite loop. + if cmuxBrowserReturnForwardingDepth > 0 { +#if DEBUG + dlog(" → browser Return/Enter reentry; using normal dispatch") +#endif + return false + } + cmuxBrowserReturnForwardingDepth += 1 + defer { cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) } +#if DEBUG + dlog(" → browser Return/Enter routed to firstResponder.keyDown") +#endif + self.firstResponder?.keyDown(with: event) + return true } if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { @@ -3623,13 +6692,30 @@ private extension NSWindow { // When the terminal is focused, skip the full NSWindow.performKeyEquivalent // (which walks the SwiftUI content view hierarchy) and dispatch Command-key // events directly to the main menu. This avoids the broken SwiftUI focus path. - if self.firstResponder is GhosttyNSView, + if firstResponderGhosttyView != nil, event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), - let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) { + let mainMenu = NSApp.mainMenu { + let consumedByMenu = mainMenu.performKeyEquivalent(with: event) #if DEBUG - dlog(" → consumed by mainMenu (bypassed SwiftUI)") + if browserZoomShortcutTraceCandidate( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode + ) { + dlog( + "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + + "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" + ) + } #endif - return true + if !consumedByMenu { + // Fall through to the original performKeyEquivalent path below. + } else { +#if DEBUG + dlog(" → consumed by mainMenu (bypassed SwiftUI)") +#endif + return true + } } let result = cmux_performKeyEquivalent(with: event) @@ -3650,4 +6736,96 @@ private extension NSWindow { parts.append("'\(chars)'(\(event.keyCode))") return parts.joined(separator: "+") } + + private static func cmuxOwningWebView(for responder: NSResponder) -> CmuxWebView? { + if let webView = responder as? CmuxWebView { + return webView + } + + if let view = responder as? NSView, + let webView = cmuxOwningWebView(for: view) { + return webView + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let webView = cmuxOwningWebView(for: delegateView) { + return webView + } + + var current = responder.nextResponder + while let next = current { + if let webView = next as? CmuxWebView { + return webView + } + if let view = next as? NSView, + let webView = cmuxOwningWebView(for: view) { + return webView + } + current = next.nextResponder + } + + return nil + } + + private static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? { + if let webView = view as? CmuxWebView { + return webView + } + + var current: NSView? = view.superview + while let candidate = current { + if let webView = candidate as? CmuxWebView { + return webView + } + current = candidate.superview + } + + return nil + } + + private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? { +#if DEBUG + if let override = cmuxFirstResponderGuardCurrentEventOverride { + return override + } +#endif + return NSApp.currentEvent + } + + private static func cmuxHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { +#if DEBUG + if let override = cmuxFirstResponderGuardHitViewOverride { + return override + } +#endif + return window.contentView?.hitTest(event.locationInWindow) + } + + private static func cmuxShouldAllowPointerInitiatedWebViewFocus( + window: NSWindow, + webView: CmuxWebView, + event: NSEvent? + ) -> Bool { + guard let event else { return false } + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + break + default: + return false + } + + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return false + } + if let eventWindow = event.window, eventWindow !== window { + return false + } + + guard let hitView = cmuxHitViewForCurrentEvent(in: window, event: event), + let hitWebView = cmuxOwningWebView(for: hitView) else { + return false + } + return hitWebView === webView + } } diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 4578fdcc..1a5ea166 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -21,9 +21,108 @@ private func browserPortalDebugFrame(_ rect: NSRect) -> String { #endif final class WindowBrowserHostView: NSView { + private struct DividerRegion { + let rectInWindow: NSRect + let isVertical: Bool + } + + private enum DividerCursorKind: Equatable { + case vertical + case horizontal + + var cursor: NSCursor { + switch self { + case .vertical: return .resizeLeftRight + case .horizontal: return .resizeUpDown + } + } + } + override var isOpaque: Bool { false } + private static let sidebarLeadingEdgeEpsilon: CGFloat = 1 + private static let minimumVisibleLeadingContentWidth: CGFloat = 24 + private var cachedSidebarDividerX: CGFloat? + private var sidebarDividerMissCount = 0 + private var trackingArea: NSTrackingArea? + private var activeDividerCursorKind: DividerCursorKind? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + clearActiveDividerCursor(restoreArrow: false) + } + window?.invalidateCursorRects(for: self) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + window?.invalidateCursorRects(for: self) + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + window?.invalidateCursorRects(for: self) + } + + override func resetCursorRects() { + super.resetCursorRects() + guard let window, let rootView = window.contentView else { return } + var regions: [DividerRegion] = [] + Self.collectSplitDividerRegions(in: rootView, into: ®ions) + let expansion: CGFloat = 4 + for region in regions { + var rectInHost = convert(region.rectInWindow, from: nil) + rectInHost = rectInHost.insetBy( + dx: region.isVertical ? -expansion : 0, + dy: region.isVertical ? 0 : -expansion + ) + let clipped = rectInHost.intersection(bounds) + guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { continue } + addCursorRect(clipped, cursor: region.isVertical ? .resizeLeftRight : .resizeUpDown) + } + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let options: NSTrackingArea.Options = [ + .inVisibleRect, + .activeAlways, + .cursorUpdate, + .mouseMoved, + .mouseEnteredAndExited, + .enabledDuringMouseDrag, + ] + let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + addTrackingArea(next) + trackingArea = next + super.updateTrackingAreas() + } + + override func cursorUpdate(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateDividerCursor(at: point) + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateDividerCursor(at: point) + } + + override func mouseExited(with event: NSEvent) { + clearActiveDividerCursor(restoreArrow: true) + } override func hitTest(_ point: NSPoint) -> NSView? { + updateDividerCursor(at: point) + + if shouldPassThroughToTitlebar(at: point) { + return nil + } + if shouldPassThroughToSidebarResizer(at: point) { + return nil + } if shouldPassThroughToSplitDivider(at: point) { return nil } @@ -31,15 +130,105 @@ final class WindowBrowserHostView: NSView { return hitView === self ? nil : hitView } - private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { + private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool { guard let window else { return false } + // Window-level portal hosts sit above SwiftUI content. Never intercept + // hits that land in native titlebar space or the custom titlebar strip + // we reserve directly under it for window drag/double-click behaviors. let windowPoint = convert(point, to: nil) - guard let rootView = window.contentView else { return false } - return Self.containsSplitDivider(at: windowPoint, in: rootView) + let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height + let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight)) + let interactionBandMinY = window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5 + return windowPoint.y >= interactionBandMinY } - private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool { - guard !view.isHidden else { return false } + private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { + // Browser portal host sits above SwiftUI content. Allow pointer/mouse events + // to reach the SwiftUI sidebar divider resizer zone. + let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView } + .filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 } + + // If content is flush to the leading edge, sidebar is effectively hidden. + // In that state, treating any internal split edge as a sidebar divider + // steals split-divider cursor/drag behavior. + let hasLeadingContent = visibleSlots.contains { + $0.frame.minX <= Self.sidebarLeadingEdgeEpsilon + && $0.frame.maxX > Self.minimumVisibleLeadingContentWidth + } + if hasLeadingContent { + if cachedSidebarDividerX != nil { + sidebarDividerMissCount += 1 + if sidebarDividerMissCount >= 2 { + cachedSidebarDividerX = nil + sidebarDividerMissCount = 0 + } + } + return false + } + + // Ignore transient 0-origin slots during layout churn and preserve the last + // known-good divider edge. + let dividerCandidates = visibleSlots + .map(\.frame.minX) + .filter { $0 > Self.sidebarLeadingEdgeEpsilon } + if let leftMostEdge = dividerCandidates.min() { + cachedSidebarDividerX = leftMostEdge + sidebarDividerMissCount = 0 + } else if cachedSidebarDividerX != nil { + // Keep cache briefly for layout churn, but clear if we miss repeatedly + // so stale divider positions don't steal pointer routing. + sidebarDividerMissCount += 1 + if sidebarDividerMissCount >= 4 { + cachedSidebarDividerX = nil + sidebarDividerMissCount = 0 + } + } + + guard let dividerX = cachedSidebarDividerX else { + return false + } + + let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide + let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide + return point.x >= regionMinX && point.x <= regionMaxX + } + + private func updateDividerCursor(at point: NSPoint) { + if shouldPassThroughToSidebarResizer(at: point) { + clearActiveDividerCursor(restoreArrow: false) + return + } + + guard let nextKind = splitDividerCursorKind(at: point) else { + clearActiveDividerCursor(restoreArrow: true) + return + } + activeDividerCursorKind = nextKind + nextKind.cursor.set() + } + + private func clearActiveDividerCursor(restoreArrow: Bool) { + guard activeDividerCursorKind != nil else { return } + window?.invalidateCursorRects(for: self) + activeDividerCursorKind = nil + if restoreArrow { + NSCursor.arrow.set() + } + } + + private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? { + guard let window else { return nil } + let windowPoint = convert(point, to: nil) + guard let rootView = window.contentView else { return nil } + return Self.dividerCursorKind(at: windowPoint, in: rootView) + } + + private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { + splitDividerCursorKind(at: point) != nil + } + + private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? { + guard !view.isHidden else { return nil } if let splitView = view as? NSSplitView { let pointInSplit = splitView.convert(windowPoint, from: nil) @@ -52,7 +241,10 @@ final class WindowBrowserHostView: NSView { let thickness = splitView.dividerThickness let dividerRect: NSRect if splitView.isVertical { - guard first.width > 1, second.width > 1 else { continue } + // Keep divider hit-testing active even when one side is nearly collapsed, + // so users can drag the divider back out from the border. + // But ignore transient states where both panes are effectively 0-width. + guard first.width > 1 || second.width > 1 else { continue } let x = max(0, first.maxX) dividerRect = NSRect( x: x, @@ -61,7 +253,8 @@ final class WindowBrowserHostView: NSView { height: splitView.bounds.height ) } else { - guard first.height > 1, second.height > 1 else { continue } + // Same behavior for horizontal splits with a near-zero-height pane. + guard first.height > 1 || second.height > 1 else { continue } let y = max(0, first.maxY) dividerRect = NSRect( x: 0, @@ -72,20 +265,56 @@ final class WindowBrowserHostView: NSView { } let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion) if expanded.contains(pointInSplit) { - return true + return splitView.isVertical ? .vertical : .horizontal } } } } for subview in view.subviews.reversed() { - if containsSplitDivider(at: windowPoint, in: subview) { - return true + if let kind = dividerCursorKind(at: windowPoint, in: subview) { + return kind } } - return false + return nil } + + private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) { + guard !view.isHidden else { return } + + if let splitView = view as? NSSplitView { + let dividerCount = max(0, splitView.arrangedSubviews.count - 1) + for dividerIndex in 0.. 1 || second.width > 1 else { continue } + let x = max(0, first.maxX) + dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height) + } else { + guard first.height > 1 || second.height > 1 else { continue } + let y = max(0, first.maxY) + dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness) + } + let dividerRectInWindow = splitView.convert(dividerRect, to: nil) + guard dividerRectInWindow.width > 0, dividerRectInWindow.height > 0 else { continue } + result.append( + DividerRegion( + rectInWindow: dividerRectInWindow, + isVertical: splitView.isVertical + ) + ) + } + } + + for subview in view.subviews { + collectSplitDividerRegions(in: subview, into: &result) + } + } + } final class WindowBrowserSlotView: NSView { @@ -112,6 +341,8 @@ final class WindowBrowserPortal: NSObject { private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] private struct Entry { weak var webView: WKWebView? @@ -131,9 +362,73 @@ final class WindowBrowserPortal: NSObject { hostView.layer?.masksToBounds = true hostView.translatesAutoresizingMaskIntoConstraints = true hostView.autoresizingMask = [] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + synchronizeAllWebViews(excluding: nil, source: "externalGeometry") + } + @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } @@ -205,13 +500,32 @@ final class WindowBrowserPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { abs(lhs.origin.x - rhs.origin.x) <= epsilon && abs(lhs.origin.y - rhs.origin.y) <= epsilon && abs(lhs.size.width - rhs.size.width) <= epsilon && abs(lhs.size.height - rhs.size.height) <= epsilon } + private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { frame.minX < bounds.minX - epsilon || frame.minY < bounds.minY - epsilon || @@ -551,7 +865,8 @@ final class WindowBrowserPortal: NSObject { _ = synchronizeHostFrameToReference() let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) let hostBounds = hostView.bounds let hasFiniteHostBounds = hostBounds.origin.x.isFinite && @@ -624,6 +939,8 @@ final class WindowBrowserPortal: NSObject { CATransaction.setDisableActions(true) containerView.frame = targetFrame CATransaction.commit() + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() } let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) @@ -738,6 +1055,7 @@ final class WindowBrowserPortal: NSObject { } func tearDown() { + removeGeometryObservers() for webViewId in Array(entriesByWebViewId.keys) { detachWebView(withId: webViewId) } diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index d8bf5463..0900b2ce 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -2,8 +2,11 @@ import Bonsplit import SwiftUI struct SurfaceSearchOverlay: View { - let surface: TerminalSurface + let tabId: UUID + let surfaceId: UUID @ObservedObject var searchState: TerminalSurface.SearchState + let onMoveFocusToTerminal: () -> Void + let onNavigateSearch: (_ action: String) -> Void let onClose: () -> Void @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @@ -44,22 +47,22 @@ struct SurfaceSearchOverlay: View { if searchState.needle.isEmpty { onClose() } else { - surface.hostedView.moveFocus() + onMoveFocusToTerminal() } } .backport.onKeyPress(.return) { modifiers in let action = modifiers.contains(.shift) ? "navigate_search:previous" : "navigate_search:next" - _ = surface.performBindingAction(action) + onNavigateSearch(action) return .handled } Button(action: { #if DEBUG - dlog("findbar.next surface=\(surface.id.uuidString.prefix(5))") + dlog("findbar.next surface=\(surfaceId.uuidString.prefix(5))") #endif - _ = surface.performBindingAction("navigate_search:next") + onNavigateSearch("navigate_search:next") }) { Image(systemName: "chevron.up") } @@ -68,9 +71,9 @@ struct SurfaceSearchOverlay: View { Button(action: { #if DEBUG - dlog("findbar.prev surface=\(surface.id.uuidString.prefix(5))") + dlog("findbar.prev surface=\(surfaceId.uuidString.prefix(5))") #endif - _ = surface.performBindingAction("navigate_search:previous") + onNavigateSearch("navigate_search:previous") }) { Image(systemName: "chevron.down") } @@ -79,7 +82,7 @@ struct SurfaceSearchOverlay: View { Button(action: { #if DEBUG - dlog("findbar.close surface=\(surface.id.uuidString.prefix(5))") + dlog("findbar.close surface=\(surfaceId.uuidString.prefix(5))") #endif onClose() }) { @@ -93,12 +96,13 @@ struct SurfaceSearchOverlay: View { .clipShape(clipShape) .shadow(radius: 4) .onAppear { - NSLog("Find: overlay appear tab=%@ surface=%@", surface.tabId.uuidString, surface.id.uuidString) + NSLog("Find: overlay appear tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString) isSearchFieldFocused = true } .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in - guard notification.object as? TerminalSurface === surface else { return } - NSLog("Find: overlay focus tab=%@ surface=%@", surface.tabId.uuidString, surface.id.uuidString) + guard let focusedSurface = notification.object as? TerminalSurface, + focusedSurface.id == surfaceId else { return } + NSLog("Find: overlay focus tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString) DispatchQueue.main.async { isSearchFieldFocused = true } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 8b2b8d14..61d7b799 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -8,6 +8,7 @@ enum KeyboardShortcutSettings { case toggleSidebar case newTab case newWindow + case closeWindow case showNotifications case jumpToUnread case triggerFlash @@ -17,6 +18,9 @@ enum KeyboardShortcutSettings { case prevSurface case nextSidebarTab case prevSidebarTab + case renameTab + case renameWorkspace + case closeWorkspace case newSurface // Panes / splits @@ -41,6 +45,7 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return "Toggle Sidebar" case .newTab: return "New Workspace" case .newWindow: return "New Window" + case .closeWindow: return "Close Window" case .showNotifications: return "Show Notifications" case .jumpToUnread: return "Jump to Latest Unread" case .triggerFlash: return "Flash Focused Panel" @@ -48,6 +53,9 @@ enum KeyboardShortcutSettings { case .prevSurface: return "Previous Surface" case .nextSidebarTab: return "Next Workspace" case .prevSidebarTab: return "Previous Workspace" + case .renameTab: return "Rename Tab" + case .renameWorkspace: return "Rename Workspace" + case .closeWorkspace: return "Close Workspace" case .newSurface: return "New Surface" case .focusLeft: return "Focus Pane Left" case .focusRight: return "Focus Pane Right" @@ -68,11 +76,15 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return "shortcut.toggleSidebar" case .newTab: return "shortcut.newTab" case .newWindow: return "shortcut.newWindow" + case .closeWindow: return "shortcut.closeWindow" case .showNotifications: return "shortcut.showNotifications" case .jumpToUnread: return "shortcut.jumpToUnread" case .triggerFlash: return "shortcut.triggerFlash" case .nextSidebarTab: return "shortcut.nextSidebarTab" case .prevSidebarTab: return "shortcut.prevSidebarTab" + case .renameTab: return "shortcut.renameTab" + case .renameWorkspace: return "shortcut.renameWorkspace" + case .closeWorkspace: return "shortcut.closeWorkspace" case .focusLeft: return "shortcut.focusLeft" case .focusRight: return "shortcut.focusRight" case .focusUp: return "shortcut.focusUp" @@ -98,6 +110,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .newWindow: return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false) + case .closeWindow: + return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true) case .showNotifications: return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) case .jumpToUnread: @@ -108,6 +122,12 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) case .prevSidebarTab: return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true) + case .renameTab: + return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false) + case .renameWorkspace: + return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) + case .closeWorkspace: + return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false) case .focusLeft: return StoredShortcut(key: "←", command: true, shift: false, option: true, control: false) case .focusRight: @@ -190,6 +210,8 @@ enum KeyboardShortcutSettings { static func nextSidebarTabShortcut() -> StoredShortcut { shortcut(for: .nextSidebarTab) } static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) } + static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) } + static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) } static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) } static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) } @@ -244,6 +266,65 @@ struct StoredShortcut: Codable, Equatable { return flags } + var keyEquivalent: KeyEquivalent? { + switch key { + case "←": + return .leftArrow + case "→": + return .rightArrow + case "↑": + return .upArrow + case "↓": + return .downArrow + case "\t": + return .tab + default: + let lowered = key.lowercased() + guard lowered.count == 1, let character = lowered.first else { return nil } + return KeyEquivalent(character) + } + } + + var eventModifiers: EventModifiers { + var modifiers: EventModifiers = [] + if command { + modifiers.insert(.command) + } + if shift { + modifiers.insert(.shift) + } + if option { + modifiers.insert(.option) + } + if control { + modifiers.insert(.control) + } + return modifiers + } + + var menuItemKeyEquivalent: String? { + switch key { + case "←": + guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "→": + guard let scalar = UnicodeScalar(NSRightArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "↑": + guard let scalar = UnicodeScalar(NSUpArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "↓": + guard let scalar = UnicodeScalar(NSDownArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "\t": + return "\t" + default: + let lowered = key.lowercased() + guard lowered.count == 1 else { return nil } + return lowered + } + } + static func from(event: NSEvent) -> StoredShortcut? { guard let key = storedKey(from: event) else { return nil } diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 45e9e3f2..53cc8737 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -5,6 +5,7 @@ struct NotificationsPage: View { @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection @FocusState private var focusedNotificationId: UUID? + @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() var body: some View { VStack(spacing: 0) { @@ -73,6 +74,8 @@ struct NotificationsPage: View { Spacer() if !notificationStore.notifications.isEmpty { + jumpToUnreadButton + Button("Clear All") { notificationStore.clearAll() } @@ -97,11 +100,76 @@ struct NotificationsPage: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + @ViewBuilder + private var jumpToUnreadButton: some View { + if let key = jumpToUnreadShortcut.keyEquivalent { + Button(action: { + AppDelegate.shared?.jumpToLatestUnread() + }) { + HStack(spacing: 6) { + Text("Jump to Latest Unread") + ShortcutAnnotation(text: jumpToUnreadShortcut.displayString) + } + } + .buttonStyle(.bordered) + .keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers) + .help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread")) + .disabled(!hasUnreadNotifications) + } else { + Button(action: { + AppDelegate.shared?.jumpToLatestUnread() + }) { + HStack(spacing: 6) { + Text("Jump to Latest Unread") + ShortcutAnnotation(text: jumpToUnreadShortcut.displayString) + } + } + .buttonStyle(.bordered) + .help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread")) + .disabled(!hasUnreadNotifications) + } + } + + private var jumpToUnreadShortcut: StoredShortcut { + decodeShortcut( + from: jumpToUnreadShortcutData, + fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut + ) + } + + private var hasUnreadNotifications: Bool { + notificationStore.notifications.contains(where: { !$0.isRead }) + } + + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { + guard !data.isEmpty, + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return fallback + } + return shortcut + } + private func tabTitle(for tabId: UUID) -> String? { AppDelegate.shared?.tabTitle(for: tabId) ?? tabManager.tabs.first(where: { $0.id == tabId })?.title } } +private struct ShortcutAnnotation: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } +} + private struct NotificationRow: View { let notification: TerminalNotification let tabTitle: String? @@ -114,11 +182,11 @@ private struct NotificationRow: View { Button(action: onOpen) { HStack(alignment: .top, spacing: 12) { Circle() - .fill(notification.isRead ? Color.clear : Color.accentColor) + .fill(notification.isRead ? Color.clear : cmuxAccentColor()) .frame(width: 8, height: 8) .overlay( Circle() - .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) ) .padding(.top, 6) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 873da962..71f297bb 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -64,10 +64,72 @@ enum BrowserSearchSettings { } } +enum BrowserThemeMode: String, CaseIterable, Identifiable { + case system + case light + case dark + + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: + return "System" + case .light: + return "Light" + case .dark: + return "Dark" + } + } + + var iconName: String { + switch self { + case .system: + return "circle.lefthalf.filled" + case .light: + return "sun.max" + case .dark: + return "moon" + } + } +} + +enum BrowserThemeSettings { + static let modeKey = "browserThemeMode" + static let legacyForcedDarkModeEnabledKey = "browserForcedDarkModeEnabled" + static let defaultMode: BrowserThemeMode = .system + + static func mode(for rawValue: String?) -> BrowserThemeMode { + guard let rawValue, let mode = BrowserThemeMode(rawValue: rawValue) else { + return defaultMode + } + return mode + } + + static func mode(defaults: UserDefaults = .standard) -> BrowserThemeMode { + let resolvedMode = mode(for: defaults.string(forKey: modeKey)) + if defaults.string(forKey: modeKey) != nil { + return resolvedMode + } + + // Migrate the legacy bool toggle only when the new mode key is unset. + if defaults.object(forKey: legacyForcedDarkModeEnabledKey) != nil { + let migratedMode: BrowserThemeMode = defaults.bool(forKey: legacyForcedDarkModeEnabledKey) ? .dark : .system + defaults.set(migratedMode.rawValue, forKey: modeKey) + return migratedMode + } + + return defaultMode + } +} + enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true + static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser" + static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true + static let browserHostWhitelistKey = "browserHostWhitelist" static let defaultBrowserHostWhitelist: String = "" @@ -78,6 +140,23 @@ enum BrowserLinkOpenSettings { return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } + static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil { + return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) + } + + // Migrate existing behavior for users who only had the link-click toggle. + if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil { + return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) + } + + return defaultInterceptTerminalOpenCommandInCmuxBrowser + } + + static func initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: UserDefaults = .standard) -> Bool { + interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults) + } + static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] { let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist return raw @@ -294,6 +373,28 @@ func browserShouldPersistInsecureHTTPAllowlistSelection( return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn } +func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest { + var preparedRequest = request + // Match browser behavior for ordinary loads while preserving method/body/headers. + preparedRequest.cachePolicy = .useProtocolCachePolicy + return preparedRequest +} + +private let browserEmbeddedNavigationSchemes: Set = [ + "about", + "applewebdata", + "blob", + "data", + "http", + "https", + "javascript", +] + +func browserShouldOpenURLExternally(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false } + return !browserEmbeddedNavigationSchemes.contains(scheme) +} + enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -998,6 +1099,44 @@ final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() + private static func clampedGhosttyBackgroundOpacity(_ opacity: Double) -> CGFloat { + CGFloat(max(0.0, min(1.0, opacity))) + } + + private static func isDarkAppearance( + appAppearance: NSAppearance? = NSApp?.effectiveAppearance + ) -> Bool { + guard let appAppearance else { return false } + return appAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + } + + private static func resolvedGhosttyBackgroundColor(from notification: Notification? = nil) -> NSColor { + let userInfo = notification?.userInfo + let baseColor = (userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor) + ?? GhosttyApp.shared.defaultBackgroundColor + + let opacity: Double + if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? Double { + opacity = value + } else if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? NSNumber { + opacity = value.doubleValue + } else { + opacity = GhosttyApp.shared.defaultBackgroundOpacity + } + + return baseColor.withAlphaComponent(clampedGhosttyBackgroundOpacity(opacity)) + } + + private static func resolvedBrowserChromeBackgroundColor( + from notification: Notification? = nil, + appAppearance: NSAppearance? = NSApp?.effectiveAppearance + ) -> NSColor { + if isDarkAppearance(appAppearance: appAppearance) { + return resolvedGhosttyBackgroundColor(from: notification) + } + return NSColor.windowBackgroundColor + } + let id: UUID let panelType: PanelType = .browser @@ -1020,6 +1159,15 @@ final class BrowserPanel: Panel, ObservableObject { /// Published URL being displayed @Published private(set) var currentURL: URL? + /// Whether the browser panel should render its WKWebView in the content area. + /// New browser tabs stay in an empty "new tab" state until first navigation. + @Published private(set) var shouldRenderWebView: Bool = false + + /// True when the browser is showing the internal empty new-tab page (no WKWebView attached yet). + var isShowingNewTabPage: Bool { + !shouldRenderWebView + } + /// Published page title @Published private(set) var pageTitle: String = "" @@ -1029,12 +1177,22 @@ final class BrowserPanel: Panel, ObservableObject { /// Published loading state @Published private(set) var isLoading: Bool = false + /// Published download state for browser downloads (navigation + context menu). + @Published private(set) var isDownloading: Bool = false + /// Published can go back state @Published private(set) var canGoBack: Bool = false /// Published can go forward state @Published private(set) var canGoForward: Bool = false + private var nativeCanGoBack: Bool = false + private var nativeCanGoForward: Bool = false + private var usesRestoredSessionHistory: Bool = false + private var restoredBackHistoryStack: [URL] = [] + private var restoredForwardHistoryStack: [URL] = [] + private var restoredHistoryCurrentURL: URL? + /// Published estimated progress (0.0 - 1.0) @Published private(set) var estimatedProgress: Double = 0.0 @@ -1048,7 +1206,9 @@ final class BrowserPanel: Panel, ObservableObject { private var cancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? + private var downloadDelegate: BrowserDownloadDelegate? private var webViewObservers: [NSKeyValueObservation] = [] + private var activeDownloadCount: Int = 0 // Avoid flickering the loading indicator for very fast navigations. private let minLoadingIndicatorDuration: TimeInterval = 0.35 @@ -1070,6 +1230,7 @@ final class BrowserPanel: Panel, ObservableObject { private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 private let developerToolsRestoreRetryMaxAttempts: Int = 40 + private var browserThemeMode: BrowserThemeMode var displayTitle: String { if !pageTitle.isEmpty { @@ -1078,7 +1239,7 @@ final class BrowserPanel: Panel, ObservableObject { if let url = currentURL { return url.host ?? url.absoluteString } - return "Browser" + return "New tab" } var displayIcon: String? { @@ -1093,6 +1254,7 @@ final class BrowserPanel: Panel, ObservableObject { self.id = UUID() self.workspaceId = workspaceId self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") + self.browserThemeMode = BrowserThemeSettings.mode() // Configure web view let config = WKWebViewConfiguration() @@ -1116,9 +1278,9 @@ final class BrowserPanel: Panel, ObservableObject { webView.isInspectable = true } - // Match the empty-page background to the window so newly-created browsers + // Match the empty-page background to the terminal theme so newly-created browsers // don't flash white before content loads. - webView.underPageBackgroundColor = .windowBackgroundColor + webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor() // Always present as Safari. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent @@ -1131,6 +1293,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserHistoryStore.shared.recordVisit(url: webView.url, title: webView.title) Task { @MainActor [weak self] in self?.refreshFavicon(from: webView) + self?.applyBrowserThemeModeIfNeeded() } } navDelegate.didFailNavigation = { [weak self] _, failedURL in @@ -1149,8 +1312,30 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false } - navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in - self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) + navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] request, intent in + self?.presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false) + } + // Set up download delegate for navigation-based downloads. + // Downloads save to a temp file synchronously (no NSSavePanel during WebKit + // callbacks), then show NSSavePanel after the download completes. + let dlDelegate = BrowserDownloadDelegate() + dlDelegate.onDownloadStarted = { [weak self] _ in + self?.beginDownloadActivity() + } + dlDelegate.onDownloadReadyToSave = { [weak self] in + self?.endDownloadActivity() + } + dlDelegate.onDownloadFailed = { [weak self] _ in + self?.endDownloadActivity() + } + navDelegate.downloadDelegate = dlDelegate + self.downloadDelegate = dlDelegate + webView.onContextMenuDownloadStateChanged = { [weak self] downloading in + if downloading { + self?.beginDownloadActivity() + } else { + self?.endDownloadActivity() + } } webView.navigationDelegate = navDelegate self.navigationDelegate = navDelegate @@ -1161,21 +1346,47 @@ final class BrowserPanel: Panel, ObservableObject { guard let self else { return } self.openLinkInNewTab(url: url) } - browserUIDelegate.requestNavigation = { [weak self] url, intent in - self?.requestNavigation(url, intent: intent) + browserUIDelegate.requestNavigation = { [weak self] request, intent in + self?.requestNavigation(request, intent: intent) } webView.uiDelegate = browserUIDelegate self.uiDelegate = browserUIDelegate // Observe web view properties setupObservers() + applyBrowserThemeModeIfNeeded() // Navigate to initial URL if provided if let url = initialURL { + shouldRenderWebView = true navigate(to: url) } } + private func beginDownloadActivity() { + let apply = { + self.activeDownloadCount += 1 + self.isDownloading = self.activeDownloadCount > 0 + } + if Thread.isMainThread { + apply() + } else { + DispatchQueue.main.async(execute: apply) + } + } + + private func endDownloadActivity() { + let apply = { + self.activeDownloadCount = max(0, self.activeDownloadCount - 1) + self.isDownloading = self.activeDownloadCount > 0 + } + if Thread.isMainThread { + apply() + } else { + DispatchQueue.main.async(execute: apply) + } + } + func updateWorkspaceId(_ newWorkspaceId: UUID) { workspaceId = newWorkspaceId } @@ -1184,6 +1395,43 @@ final class BrowserPanel: Panel, ObservableObject { focusFlashToken &+= 1 } + func sessionNavigationHistorySnapshot() -> ( + backHistoryURLStrings: [String], + forwardHistoryURLStrings: [String] + ) { + if usesRestoredSessionHistory { + let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) } + // `restoredForwardHistoryStack` stores nearest-forward entries at the end. + let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) } + return (back, forward) + } + + let back = webView.backForwardList.backList.compactMap { + Self.serializableSessionHistoryURLString($0.url) + } + let forward = webView.backForwardList.forwardList.compactMap { + Self.serializableSessionHistoryURLString($0.url) + } + return (back, forward) + } + + func restoreSessionNavigationHistory( + backHistoryURLStrings: [String], + forwardHistoryURLStrings: [String], + currentURLString: String? + ) { + let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings) + let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings) + guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return } + + usesRestoredSessionHistory = true + restoredBackHistoryStack = restoredBack + // Store nearest-forward entries at the end to make stack pop operations trivial. + restoredForwardHistoryStack = Array(restoredForward.reversed()) + restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString) + refreshNavigationAvailability() + } + private func setupObservers() { // URL changes let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in @@ -1217,7 +1465,9 @@ final class BrowserPanel: Panel, ObservableObject { // Can go back let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.canGoBack = webView.canGoBack + guard let self else { return } + self.nativeCanGoBack = webView.canGoBack + self.refreshNavigationAvailability() } } webViewObservers.append(backObserver) @@ -1225,7 +1475,9 @@ final class BrowserPanel: Panel, ObservableObject { // Can go forward let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in Task { @MainActor in - self?.canGoForward = webView.canGoForward + guard let self else { return } + self.nativeCanGoForward = webView.canGoForward + self.refreshNavigationAvailability() } } webViewObservers.append(forwardObserver) @@ -1237,6 +1489,13 @@ final class BrowserPanel: Panel, ObservableObject { } } webViewObservers.append(progressObserver) + + NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange) + .sink { [weak self] notification in + guard let self else { return } + self.webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor(from: notification) + } + .store(in: &cancellables) } // MARK: - Panel Protocol @@ -1279,6 +1538,7 @@ final class BrowserPanel: Panel, ObservableObject { navigationDelegate = nil uiDelegate = nil webViewObservers.removeAll() + cancellables.removeAll() faviconTask?.cancel() faviconTask = nil } @@ -1435,6 +1695,9 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask?.cancel() faviconTask = nil lastFaviconURLString = nil + // Clear the previous page's favicon so it never persists across navigations. + // The loading spinner covers this gap; didFinish will fetch the new favicon. + faviconPNGData = nil loadingGeneration &+= 1 loadingEndWorkItem?.cancel() loadingEndWorkItem = nil @@ -1472,24 +1735,44 @@ final class BrowserPanel: Panel, ObservableObject { /// Navigate to a URL func navigate(to url: URL, recordTypedNavigation: Bool = false) { + let request = URLRequest(url: url) if shouldBlockInsecureHTTPNavigation(to: url) { - presentInsecureHTTPAlert(for: url, intent: .currentTab, recordTypedNavigation: recordTypedNavigation) + presentInsecureHTTPAlert(for: request, intent: .currentTab, recordTypedNavigation: recordTypedNavigation) return } - navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) } - private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) { + private func navigateWithoutInsecureHTTPPrompt( + to url: URL, + recordTypedNavigation: Bool, + preserveRestoredSessionHistory: Bool = false + ) { + let request = URLRequest(url: url) + navigateWithoutInsecureHTTPPrompt( + request: request, + recordTypedNavigation: recordTypedNavigation, + preserveRestoredSessionHistory: preserveRestoredSessionHistory + ) + } + + private func navigateWithoutInsecureHTTPPrompt( + request: URLRequest, + recordTypedNavigation: Bool, + preserveRestoredSessionHistory: Bool = false + ) { + guard let url = request.url else { return } + if !preserveRestoredSessionHistory { + abandonRestoredSessionHistoryIfNeeded() + } // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + shouldRenderWebView = true if recordTypedNavigation { BrowserHistoryStore.shared.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url - var request = URLRequest(url: url) - // Behave like a normal browser (respect HTTP caching). Reload is handled separately. - request.cachePolicy = .useProtocolCachePolicy - webView.load(request) + webView.load(browserPreparedNavigationRequest(request)) } /// Navigate with smart URL/search detection @@ -1520,24 +1803,26 @@ final class BrowserPanel: Panel, ObservableObject { return browserShouldBlockInsecureHTTPURL(url) } - private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) { + private func requestNavigation(_ request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent) { + guard let url = request.url else { return } if shouldBlockInsecureHTTPNavigation(to: url) { - presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) + presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false) return } switch intent { case .currentTab: - navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: false) + navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: false) case .newTab: openLinkInNewTab(url: url) } } private func presentInsecureHTTPAlert( - for url: URL, + for request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent, recordTypedNavigation: Bool ) { + guard let url = request.url else { return } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } let alert = NSAlert() @@ -1568,7 +1853,7 @@ final class BrowserPanel: Panel, ObservableObject { switch intent { case .currentTab: insecureHTTPBypassHostOnce = host - navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) case .newTab: openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host) } @@ -1585,6 +1870,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserWindowPortalRegistry.detach(webView: webView) } webViewObservers.removeAll() + cancellables.removeAll() } } @@ -1623,26 +1909,90 @@ extension BrowserPanel { /// Go back in history func goBack() { guard canGoBack else { return } + if usesRestoredSessionHistory { + guard let targetURL = restoredBackHistoryStack.popLast() else { + refreshNavigationAvailability() + return + } + if let current = resolvedCurrentSessionHistoryURL() { + restoredForwardHistoryStack.append(current) + } + restoredHistoryCurrentURL = targetURL + refreshNavigationAvailability() + navigateWithoutInsecureHTTPPrompt( + to: targetURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + return + } + webView.goBack() } /// Go forward in history func goForward() { guard canGoForward else { return } + if usesRestoredSessionHistory { + guard let targetURL = restoredForwardHistoryStack.popLast() else { + refreshNavigationAvailability() + return + } + if let current = resolvedCurrentSessionHistoryURL() { + restoredBackHistoryStack.append(current) + } + restoredHistoryCurrentURL = targetURL + refreshNavigationAvailability() + navigateWithoutInsecureHTTPPrompt( + to: targetURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + return + } + webView.goForward() } /// Open a link in a new browser surface in the same pane func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { - guard let tabManager = AppDelegate.shared?.tabManager, - let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }), - let paneId = workspace.paneId(forPanelId: id) else { return } +#if DEBUG + dlog( + "browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " + + "workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " + + "bypass=\(bypassInsecureHTTPHostOnce ?? "nil")" + ) +#endif + guard let tabManager = AppDelegate.shared?.tabManager else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingTabManager") +#endif + return + } + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing") +#endif + return + } + guard let paneId = workspace.paneId(forPanelId: id) else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing") +#endif + return + } workspace.newBrowserSurface( inPane: paneId, url: url, focus: true, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) +#if DEBUG + dlog( + "browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))" + ) +#endif } /// Reload the current page @@ -1873,6 +2223,15 @@ extension BrowserPanel { try await webView.evaluateJavaScript(script) } + func setBrowserThemeMode(_ mode: BrowserThemeMode) { + browserThemeMode = mode + applyBrowserThemeModeIfNeeded() + } + + func refreshAppearanceDrivenColors() { + webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor() + } + func suppressOmnibarAutofocus(for seconds: TimeInterval) { suppressOmnibarAutofocusUntil = Date().addingTimeInterval(seconds) } @@ -1903,10 +2262,20 @@ extension BrowserPanel { } func beginSuppressWebViewFocusForAddressBar() { + if !suppressWebViewFocusForAddressBar { +#if DEBUG + dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") +#endif + } suppressWebViewFocusForAddressBar = true } func endSuppressWebViewFocusForAddressBar() { + if suppressWebViewFocusForAddressBar { +#if DEBUG + dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))") +#endif + } suppressWebViewFocusForAddressBar = false } @@ -1946,9 +2315,127 @@ extension BrowserPanel { return nil } + private func resolvedCurrentSessionHistoryURL() -> URL? { + if let webViewURL = webView.url, + Self.serializableSessionHistoryURLString(webViewURL) != nil { + return webViewURL + } + if let currentURL, + Self.serializableSessionHistoryURLString(currentURL) != nil { + return currentURL + } + return restoredHistoryCurrentURL + } + + private func refreshNavigationAvailability() { + let resolvedCanGoBack: Bool + let resolvedCanGoForward: Bool + if usesRestoredSessionHistory { + resolvedCanGoBack = !restoredBackHistoryStack.isEmpty + resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty + } else { + resolvedCanGoBack = nativeCanGoBack + resolvedCanGoForward = nativeCanGoForward + } + + if canGoBack != resolvedCanGoBack { + canGoBack = resolvedCanGoBack + } + if canGoForward != resolvedCanGoForward { + canGoForward = resolvedCanGoForward + } + } + + private func abandonRestoredSessionHistoryIfNeeded() { + guard usesRestoredSessionHistory else { return } + usesRestoredSessionHistory = false + restoredBackHistoryStack.removeAll(keepingCapacity: false) + restoredForwardHistoryStack.removeAll(keepingCapacity: false) + restoredHistoryCurrentURL = nil + refreshNavigationAvailability() + } + + private static func serializableSessionHistoryURLString(_ url: URL?) -> String? { + guard let url else { return nil } + let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value != "about:blank" else { return nil } + return value + } + + private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil } + return URL(string: trimmed) + } + + private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] { + values.compactMap { sanitizedSessionHistoryURL($0) } + } + } private extension BrowserPanel { + func applyBrowserThemeModeIfNeeded() { + switch browserThemeMode { + case .system: + webView.appearance = nil + case .light: + webView.appearance = NSAppearance(named: .aqua) + case .dark: + webView.appearance = NSAppearance(named: .darkAqua) + } + + let script = makeBrowserThemeModeScript(mode: browserThemeMode) + webView.evaluateJavaScript(script) { _, error in + #if DEBUG + if let error { + dlog("browser.themeMode error=\(error.localizedDescription)") + } + #endif + } + } + + func makeBrowserThemeModeScript(mode: BrowserThemeMode) -> String { + let colorSchemeLiteral: String + switch mode { + case .system: + colorSchemeLiteral = "null" + case .light: + colorSchemeLiteral = "'light'" + case .dark: + colorSchemeLiteral = "'dark'" + } + + return """ + (() => { + const metaId = 'cmux-browser-theme-mode-meta'; + const colorScheme = \(colorSchemeLiteral); + const root = document.documentElement || document.body; + if (!root) return; + + let meta = document.getElementById(metaId); + if (colorScheme) { + root.style.setProperty('color-scheme', colorScheme, 'important'); + root.setAttribute('data-cmux-browser-theme', colorScheme); + if (!meta) { + meta = document.createElement('meta'); + meta.id = metaId; + meta.name = 'color-scheme'; + (document.head || root).appendChild(meta); + } + meta.setAttribute('content', colorScheme); + } else { + root.style.removeProperty('color-scheme'); + root.removeAttribute('data-cmux-browser-theme'); + if (meta) { + meta.remove(); + } + } + })(); + """ + } + func scheduleDeveloperToolsRestoreRetry() { guard preferredDeveloperToolsVisible else { return } guard developerToolsRestoreRetryWorkItem == nil else { return } @@ -2076,14 +2563,176 @@ private extension NSObject { } } +// MARK: - Download Delegate + +/// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI +/// during WebKit callbacks), then showing NSSavePanel after the download finishes. +private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { + private struct DownloadState { + let tempURL: URL + let suggestedFilename: String + } + + /// Tracks active downloads keyed by WKDownload identity. + private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] + private let activeDownloadsLock = NSLock() + var onDownloadStarted: ((String) -> Void)? + var onDownloadReadyToSave: (() -> Void)? + var onDownloadFailed: ((Error) -> Void)? + + private static let tempDir: URL = { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-downloads", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + private static func sanitizedFilename(_ raw: String, fallbackURL: URL?) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let candidate = (trimmed as NSString).lastPathComponent + let fromURL = fallbackURL?.lastPathComponent ?? "" + let base = candidate.isEmpty ? fromURL : candidate + let replaced = base.replacingOccurrences(of: ":", with: "-") + let safe = replaced.trimmingCharacters(in: .whitespacesAndNewlines) + return safe.isEmpty ? "download" : safe + } + + private func storeState(_ state: DownloadState, for download: WKDownload) { + activeDownloadsLock.lock() + activeDownloads[ObjectIdentifier(download)] = state + activeDownloadsLock.unlock() + } + + private func removeState(for download: WKDownload) -> DownloadState? { + activeDownloadsLock.lock() + let state = activeDownloads.removeValue(forKey: ObjectIdentifier(download)) + activeDownloadsLock.unlock() + return state + } + + private func notifyOnMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } + } + + func download( + _ download: WKDownload, + decideDestinationUsing response: URLResponse, + suggestedFilename: String, + completionHandler: @escaping (URL?) -> Void + ) { + // Save to a temp file — return synchronously so WebKit is never blocked. + let safeFilename = Self.sanitizedFilename(suggestedFilename, fallbackURL: response.url) + let tempFilename = "\(UUID().uuidString)-\(safeFilename)" + let destURL = Self.tempDir.appendingPathComponent(tempFilename, isDirectory: false) + try? FileManager.default.removeItem(at: destURL) + storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename), for: download) + notifyOnMain { [weak self] in + self?.onDownloadStarted?(safeFilename) + } + #if DEBUG + dlog("download.decideDestination file=\(safeFilename)") + #endif + NSLog("BrowserPanel download: temp path=%@", destURL.path) + completionHandler(destURL) + } + + func downloadDidFinish(_ download: WKDownload) { + guard let info = removeState(for: download) else { + #if DEBUG + dlog("download.finished missing-state") + #endif + return + } + #if DEBUG + dlog("download.finished file=\(info.suggestedFilename)") + #endif + NSLog("BrowserPanel download finished: %@", info.suggestedFilename) + + // Show NSSavePanel on the next runloop iteration (safe context). + DispatchQueue.main.async { + self.onDownloadReadyToSave?() + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = info.suggestedFilename + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { + try? FileManager.default.removeItem(at: info.tempURL) + return + } + do { + try? FileManager.default.removeItem(at: destURL) + try FileManager.default.moveItem(at: info.tempURL, to: destURL) + NSLog("BrowserPanel download saved: %@", destURL.path) + } catch { + NSLog("BrowserPanel download move failed: %@", error.localizedDescription) + try? FileManager.default.removeItem(at: info.tempURL) + } + } + } + } + + func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + if let info = removeState(for: download) { + try? FileManager.default.removeItem(at: info.tempURL) + } + notifyOnMain { [weak self] in + self?.onDownloadFailed?(error) + } + #if DEBUG + dlog("download.failed error=\(error.localizedDescription)") + #endif + NSLog("BrowserPanel download failed: %@", error.localizedDescription) + } +} + // MARK: - Navigation Delegate +func browserNavigationShouldOpenInNewTab( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + guard navigationType == .linkActivated || navigationType == .other else { + return false + } + + if modifierFlags.contains(.command) { + return true + } + if buttonNumber == 2 { + return true + } + // In some WebKit paths, middle-click arrives as buttonNumber=4. + // Recover intent when we just observed a local middle-click. + if buttonNumber == 4, hasRecentMiddleClickIntent { + return true + } + + // WebKit can omit buttonNumber for middle-click link activations. + if let currentEventType, + (currentEventType == .otherMouseDown || currentEventType == .otherMouseUp), + currentEventButtonNumber == 2 { + return true + } + return false +} + private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? var openInNewTab: ((URL) -> Void)? var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? - var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)? + var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + /// Direct reference to the download delegate — must be set synchronously in didBecome callbacks. + var downloadDelegate: WKDownloadDelegate? /// The URL of the last navigation that was attempted. Used to preserve the omnibar URL /// when a provisional navigation fails (e.g. connection refused on localhost:3000). var lastAttemptedURL: URL? @@ -2098,6 +2747,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { NSLog("BrowserPanel navigation failed: %@", error.localizedDescription) + // Treat committed-navigation failures the same as provisional ones so + // stale favicon/title state from the prior page gets cleared. + let failedURL = webView.url?.absoluteString ?? "" + didFailNavigation?(webView, failedURL) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { @@ -2109,6 +2762,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } + // "Frame load interrupted" (WebKitErrorDomain code 102) fires when a + // navigation response is converted into a download via .download policy. + // This is expected and should not show an error page. + if nsError.domain == "WebKitErrorDomain", nsError.code == 102 { + return + } + let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String ?? lastAttemptedURL?.absoluteString ?? "" @@ -2203,65 +2863,244 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { + let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) + let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent + ) +#if DEBUG + let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" + let navType = String(describing: navigationAction.navigationType) + dlog( + "browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " + + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + + "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + + "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" + ) +#endif + if let url = navigationAction.request.url, navigationAction.targetFrame?.isMainFrame != false, shouldBlockInsecureHTTPNavigation?(url) == true { let intent: BrowserInsecureHTTPNavigationIntent - if navigationAction.navigationType == .linkActivated, - navigationAction.modifierFlags.contains(.command) { + if shouldOpenInNewTab { intent = .newTab } else { intent = .currentTab } - handleBlockedInsecureHTTPNavigation?(url, intent) +#if DEBUG + dlog( + "browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " + + "url=\(url.absoluteString)" + ) +#endif + handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent) decisionHandler(.cancel) return } - // target=_blank or window.open() — navigate in the current webview - if navigationAction.targetFrame == nil, - let url = navigationAction.request.url { - webView.load(URLRequest(url: url)) + // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). + // Hand these off to macOS so the owning app can handle them. + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif decisionHandler(.cancel) return } - // Cmd+click on a regular link — open in a new tab - if navigationAction.navigationType == .linkActivated, - navigationAction.modifierFlags.contains(.command), + // Cmd+click and middle-click on regular links should always open in a new tab. + if shouldOpenInNewTab, let url = navigationAction.request.url { +#if DEBUG + dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)") +#endif openInNewTab?(url) decisionHandler(.cancel) return } + // target=_blank or window.open() without explicit new-tab intent — navigate in-place. + if navigationAction.targetFrame == nil, + navigationAction.request.url != nil { +#if DEBUG + let targetURL = navigationAction.request.url?.absoluteString ?? "nil" + dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)") +#endif + webView.load(navigationAction.request) + decisionHandler(.cancel) + return + } + +#if DEBUG + let targetURL = navigationAction.request.url?.absoluteString ?? "nil" + dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") +#endif decisionHandler(.allow) } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if !navigationResponse.isForMainFrame { + decisionHandler(.allow) + return + } + + let mime = navigationResponse.response.mimeType ?? "unknown" + let canShow = navigationResponse.canShowMIMEType + let responseURL = navigationResponse.response.url?.absoluteString ?? "nil" + + // Only classify HTTP(S) top-level responses as downloads. + if let scheme = navigationResponse.response.url?.scheme?.lowercased(), + scheme != "http", scheme != "https" { + decisionHandler(.allow) + return + } + + NSLog("BrowserPanel navigationResponse: url=%@ mime=%@ canShow=%d isMainFrame=%d", + responseURL, mime, canShow ? 1 : 0, + navigationResponse.isForMainFrame ? 1 : 0) + + // Check if this response should be treated as a download. + // Criteria: explicit Content-Disposition: attachment, or a MIME type + // that WebKit cannot render inline. + if let response = navigationResponse.response as? HTTPURLResponse { + let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? "" + if contentDisposition.lowercased().hasPrefix("attachment") { + NSLog("BrowserPanel download: content-disposition=attachment mime=%@ url=%@", mime, responseURL) + #if DEBUG + dlog("download.policy=download reason=content-disposition mime=\(mime)") + #endif + decisionHandler(.download) + return + } + } + + if !canShow { + NSLog("BrowserPanel download: cannotShowMIME mime=%@ url=%@", mime, responseURL) + #if DEBUG + dlog("download.policy=download reason=cannotShowMIME mime=\(mime)") + #endif + decisionHandler(.download) + return + } + + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + #if DEBUG + dlog("download.didBecome source=navigationAction") + #endif + NSLog("BrowserPanel download didBecome from navigationAction") + download.delegate = downloadDelegate + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + #if DEBUG + dlog("download.didBecome source=navigationResponse") + #endif + NSLog("BrowserPanel download didBecome from navigationResponse") + download.delegate = downloadDelegate + } } // MARK: - UI Delegate private class BrowserUIDelegate: NSObject, WKUIDelegate { var openInNewTab: ((URL) -> Void)? - var requestNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)? + var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + + private func javaScriptDialogTitle(for webView: WKWebView) -> String { + if let absolute = webView.url?.absoluteString, !absolute.isEmpty { + return "The page at \(absolute) says:" + } + return "This page says:" + } + + private func presentDialog( + _ alert: NSAlert, + for webView: WKWebView, + completion: @escaping (NSApplication.ModalResponse) -> Void + ) { + if let window = webView.window { + alert.beginSheetModal(for: window, completionHandler: completion) + return + } + completion(alert.runModal()) + } /// Returning nil tells WebKit not to open a new window. - /// Cmd+click opens in a new tab; regular target=_blank navigates in-place. + /// Cmd+click and middle-click open in a new tab; regular target=_blank navigates in-place. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { + let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) + let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent + ) +#if DEBUG + let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" + let navType = String(describing: navigationAction.navigationType) + dlog( + "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + + "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + + "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" + ) +#endif if let url = navigationAction.request.url { + if browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + return nil + } if let requestNavigation { let intent: BrowserInsecureHTTPNavigationIntent = - navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab - requestNavigation(url, intent) - } else if navigationAction.modifierFlags.contains(.command) { + shouldOpenInNewTab ? .newTab : .currentTab +#if DEBUG + dlog( + "browser.nav.createWebView.action kind=requestNavigation intent=\(intent == .newTab ? "newTab" : "currentTab") " + + "url=\(url.absoluteString)" + ) +#endif + requestNavigation(navigationAction.request, intent) + } else if shouldOpenInNewTab { +#if DEBUG + dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") +#endif openInNewTab?(url) } else { - webView.load(URLRequest(url: url)) +#if DEBUG + dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)") +#endif + webView.load(navigationAction.request) } } return nil @@ -2282,4 +3121,62 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { completionHandler(result == .OK ? panel.urls : nil) } } + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = message + alert.addButton(withTitle: "OK") + presentDialog(alert, for: webView) { _ in completionHandler() } + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + presentDialog(alert, for: webView) { response in + completionHandler(response == .alertFirstButtonReturn) + } + } + + func webView( + _ webView: WKWebView, + runJavaScriptTextInputPanelWithPrompt prompt: String, + defaultText: String?, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (String?) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = prompt + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + field.stringValue = defaultText ?? "" + alert.accessoryView = field + + presentDialog(alert, for: webView) { response in + if response == .alertFirstButtonReturn { + completionHandler(field.stringValue) + } else { + completionHandler(nil) + } + } + } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index ef747d75..699b856a 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -71,7 +71,7 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { // Matches Bonsplit tab icon tint for active tabs. return Color(nsColor: .labelColor) case .accent: - return .accentColor + return cmuxAccentColor() case .tertiary: return Color(nsColor: .tertiaryLabelColor) } @@ -122,6 +122,90 @@ struct OmnibarInlineCompletion: Equatable { } } +private struct OmnibarAddressButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + OmnibarAddressButtonStyleBody(configuration: configuration) + } +} + +private struct OmnibarAddressButtonStyleBody: View { + let configuration: OmnibarAddressButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + +private extension View { + @ViewBuilder + func cmuxFlatSymbolColorRendering() -> some View { + if #available(macOS 26.0, *) { + self.symbolColorRenderingMode(.flat) + } else { + self + } + } +} + +func resolvedBrowserChromeBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> NSColor { + switch colorScheme { + case .dark, .light: + return themeBackgroundColor + @unknown default: + return themeBackgroundColor + } +} + +func resolvedBrowserChromeColorScheme( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> ColorScheme { + let backgroundColor = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackgroundColor + ) + return backgroundColor.isLightColor ? .light : .dark +} + +func resolvedBrowserOmnibarPillBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> NSColor { + let darkenMix: CGFloat + switch colorScheme { + case .light: + darkenMix = 0.04 + case .dark: + darkenMix = 0.05 + @unknown default: + darkenMix = 0.04 + } + + return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel @@ -129,12 +213,14 @@ struct BrowserPanelView: View { let isVisibleInUI: Bool let portalPriority: Int let onRequestPanelFocus: () -> Void + @Environment(\.colorScheme) private var colorScheme @State private var omnibarState = OmnibarState() @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue @State private var suggestionTask: Task? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @@ -144,12 +230,16 @@ struct BrowserPanelView: View { @State private var omnibarHasMarkedText: Bool = false @State private var suppressNextFocusLostRevert: Bool = false @State private var focusFlashOpacity: Double = 0.0 - @State private var focusFlashFadeWorkItem: DispatchWorkItem? + @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? - private let omnibarPillCornerRadius: CGFloat = 12 + @State private var isBrowserThemeMenuPresented = false + // Keep this below half of the compact omnibar height so it reads as a squircle, + // not a capsule. + private let omnibarPillCornerRadius: CGFloat = 10 private let addressBarButtonSize: CGFloat = 22 - private let addressBarButtonHitSize: CGFloat = 32 + private let addressBarButtonHitSize: CGFloat = 26 + private let addressBarVerticalPadding: CGFloat = 4 private let devToolsButtonIconSize: CGFloat = 11 private var searchEngine: BrowserSearchEngine { @@ -184,16 +274,41 @@ struct BrowserPanelView: View { BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor } + private var browserThemeMode: BrowserThemeMode { + BrowserThemeSettings.mode(for: browserThemeModeRaw) + } + + private var browserChromeBackgroundColor: NSColor { + resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + ) + } + + private var browserChromeColorScheme: ColorScheme { + resolvedBrowserChromeColorScheme( + for: colorScheme, + themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + ) + } + + private var omnibarPillBackgroundColor: NSColor { + resolvedBrowserOmnibarPillBackgroundColor( + for: browserChromeColorScheme, + themeBackgroundColor: browserChromeBackgroundColor + ) + } + var body: some View { VStack(spacing: 0) { addressBar webView } .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3) - .shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10) - .padding(6) + RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) + .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) + .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) + .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } .overlay(alignment: .topLeading) { @@ -213,8 +328,9 @@ struct BrowserPanelView: View { } ) .frame(width: omnibarPillFrame.width) - .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6) + .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3) .zIndex(1000) + .environment(\.colorScheme, browserChromeColorScheme) } } .coordinateSpace(name: "BrowserPanelViewSpace") @@ -226,25 +342,32 @@ struct BrowserPanelView: View { guard let webView = note.object as? CmuxWebView else { return false } return webView === panel?.webView }) { _ in +#if DEBUG + dlog( + "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " + + "isFocused=\(isFocused ? 1 : 0) " + + "addressFocused=\(addressBarFocused ? 1 : 0)" + ) +#endif onRequestPanelFocus() } - .onReceive(NotificationCenter.default.publisher(for: .webViewMiddleClickedLink).filter { [weak panel] note in - guard let webView = note.object as? CmuxWebView else { return false } - return webView === panel?.webView - }) { note in - if let url = note.userInfo?["url"] as? URL { - panel.openLinkInNewTab(url: url) - } - } .onAppear { UserDefaults.standard.register(defaults: [ BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled, + BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, ]) + let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) + if browserThemeModeRaw != resolvedThemeMode.rawValue { + browserThemeModeRaw = resolvedThemeMode.rawValue + } + panel.refreshAppearanceDrivenColors() + panel.setBrowserThemeMode(browserThemeMode) applyPendingAddressBarFocusRequestIfNeeded() syncURLFromPanel() // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. autoFocusOmnibarIfBlank() + syncWebViewResponderPolicyWithViewState(reason: "onAppear") BrowserHistoryStore.shared.loadIfNeeded() } .onChange(of: panel.focusFlashToken) { _ in @@ -262,6 +385,16 @@ struct BrowserPanelView: View { addressBarFocused = false } } + .onChange(of: browserThemeModeRaw) { _ in + let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) + if browserThemeModeRaw != normalizedMode.rawValue { + browserThemeModeRaw = normalizedMode.rawValue + } + panel.setBrowserThemeMode(normalizedMode) + } + .onChange(of: colorScheme) { _ in + panel.refreshAppearanceDrivenColors() + } .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in applyPendingAddressBarFocusRequestIfNeeded() } @@ -274,6 +407,7 @@ struct BrowserPanelView: View { hideSuggestions() addressBarFocused = false } + syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged") } .onChange(of: addressBarFocused) { focused in let urlString = panel.preferredURLStringForOmnibar() ?? "" @@ -301,6 +435,7 @@ struct BrowserPanelView: View { } inlineCompletion = nil } + syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged") } .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in guard let panelId = notification.object as? UUID, panelId == panel.id else { return } @@ -332,13 +467,17 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") - developerToolsButton + if !panel.isShowingNewTabPage { + browserThemeModeButton + developerToolsButton + } } .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color(nsColor: .windowBackgroundColor)) + .padding(.vertical, addressBarVerticalPadding) + .background(Color(nsColor: browserChromeBackgroundColor)) // Keep the omnibar stack above WKWebView so the suggestions popup is visible. .zIndex(1) + .environment(\.colorScheme, browserChromeColorScheme) } private var addressBarButtonBar: some View { @@ -354,7 +493,7 @@ struct BrowserPanelView: View { .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(OmnibarAddressButtonStyle()) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) .help("Go Back") @@ -370,7 +509,7 @@ struct BrowserPanelView: View { .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(OmnibarAddressButtonStyle()) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) .help("Go Forward") @@ -393,8 +532,20 @@ struct BrowserPanelView: View { .frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(OmnibarAddressButtonStyle()) .help(panel.isLoading ? "Stop" : "Reload") + + if panel.isDownloading { + HStack(spacing: 4) { + ProgressView() + .controlSize(.small) + Text("Downloading...") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(.leading, 6) + .help("Download in progress") + } } } @@ -403,16 +554,74 @@ struct BrowserPanelView: View { openDevTools() }) { Image(systemName: devToolsIconOption.rawValue) + .symbolRenderingMode(.monochrome) + .cmuxFlatSymbolColorRendering() .font(.system(size: devToolsButtonIconSize, weight: .medium)) .foregroundStyle(devToolsColorOption.color) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } - .buttonStyle(.plain) + .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .help("Toggle Developer Tools") + .help(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip("Toggle Developer Tools")) .accessibilityIdentifier("BrowserToggleDevToolsButton") } + private var browserThemeModeButton: some View { + Button(action: { + isBrowserThemeMenuPresented.toggle() + }) { + Image(systemName: browserThemeMode.iconName) + .symbolRenderingMode(.monochrome) + .cmuxFlatSymbolColorRendering() + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(browserThemeModeIconColor) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) { + browserThemeModePopover + } + .help("Browser Theme: \(browserThemeMode.displayName)") + .accessibilityIdentifier("BrowserThemeModeButton") + } + + private var browserThemeModePopover: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(BrowserThemeMode.allCases) { mode in + Button { + applyBrowserThemeModeSelection(mode) + isBrowserThemeMenuPresented = false + } label: { + HStack(spacing: 8) { + Image(systemName: mode == browserThemeMode ? "checkmark" : "circle") + .font(.system(size: 10, weight: .semibold)) + .opacity(mode == browserThemeMode ? 1.0 : 0.0) + .frame(width: 12, alignment: .center) + Text(mode.displayName) + .font(.system(size: 12)) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(mode == browserThemeMode ? Color.primary.opacity(0.12) : Color.clear) + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("BrowserThemeModeOption\(mode.rawValue.capitalized)") + } + } + .padding(8) + .frame(minWidth: 128) + } + + private var browserThemeModeIconColor: Color { + devToolsColorOption.color + } + private var omnibarField: some View { let showSecureBadge = panel.currentURL?.scheme == "https" @@ -483,11 +692,11 @@ struct BrowserPanelView: View { .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) + .fill(Color(nsColor: omnibarPillBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) - .stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1) + .stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1) ) .accessibilityElement(children: .contain) .background { @@ -502,42 +711,77 @@ struct BrowserPanelView: View { } private var webView: some View { - WebViewRepresentable( - panel: panel, - shouldAttachWebView: isVisibleInUI, - shouldFocusWebView: isFocused && !addressBarFocused, - isPanelFocused: isFocused, - portalZPriority: portalPriority - ) - // Keep the representable identity stable across bonsplit structural updates. - // This reduces WKWebView reparenting churn (and the associated WebKit crashes). - .id(panel.id) - .contentShape(Rectangle()) - .simultaneousGesture(TapGesture().onEnded { - // Chrome-like behavior: clicking web content while editing the - // omnibar should commit blur and revert transient edits. - if addressBarFocused { - addressBarFocused = false - } - }) - .zIndex(0) + Group { + if panel.shouldRenderWebView { + WebViewRepresentable( + panel: panel, + shouldAttachWebView: isVisibleInUI, + shouldFocusWebView: isFocused && !addressBarFocused, + isPanelFocused: isFocused, + portalZPriority: portalPriority + ) + // Keep the representable identity stable across bonsplit structural updates. + // This reduces WKWebView reparenting churn (and the associated WebKit crashes). + .id(panel.id) + .contentShape(Rectangle()) + .simultaneousGesture(TapGesture().onEnded { + // Chrome-like behavior: clicking web content while editing the + // omnibar should commit blur and revert transient edits. + if addressBarFocused { + addressBarFocused = false + } + }) + } else { + Color(nsColor: browserChromeBackgroundColor) + .contentShape(Rectangle()) + .onTapGesture { + onRequestPanelFocus() + if addressBarFocused { + addressBarFocused = false + } + } + } + } + .zIndex(0) } private func triggerFocusFlashAnimation() { - focusFlashFadeWorkItem?.cancel() - focusFlashFadeWorkItem = nil + focusFlashAnimationGeneration &+= 1 + let generation = focusFlashAnimationGeneration + focusFlashOpacity = FocusFlashPattern.values.first ?? 0 - withAnimation(.easeOut(duration: 0.08)) { - focusFlashOpacity = 1.0 - } - - let item = DispatchWorkItem { - withAnimation(.easeOut(duration: 0.35)) { - focusFlashOpacity = 0.0 + for segment in FocusFlashPattern.segments { + DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { + guard focusFlashAnimationGeneration == generation else { return } + withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { + focusFlashOpacity = segment.targetOpacity + } } } - focusFlashFadeWorkItem = item - DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: item) + } + + private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation { + switch curve { + case .easeIn: + return .easeIn(duration: duration) + case .easeOut: + return .easeOut(duration: duration) + } + } + + private func syncWebViewResponderPolicyWithViewState(reason: String) { + guard let cmuxWebView = panel.webView as? CmuxWebView else { return } + let next = isFocused && !panel.shouldSuppressWebViewFocus() + if cmuxWebView.allowsFirstResponderAcquisition != next { +#if DEBUG + dlog( + "browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " + + "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + + "new=\(next ? 1 : 0) reason=\(reason)" + ) +#endif + } + cmuxWebView.allowsFirstResponderAcquisition = next } private func syncURLFromPanel() { @@ -546,8 +790,32 @@ struct BrowserPanelView: View { applyOmnibarEffects(effects) } + private func isCommandPaletteVisibleForPanelWindow() -> Bool { + guard let app = AppDelegate.shared else { return false } + + if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) { + return true + } + + if let manager = app.tabManagerFor(tabId: panel.workspaceId), + let windowId = app.windowId(for: manager), + let window = app.mainWindow(for: windowId), + app.isCommandPaletteVisible(for: window) { + return true + } + + if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) { + return true + } + if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) { + return true + } + return false + } + private func applyPendingAddressBarFocusRequestIfNeeded() { guard let requestId = panel.pendingAddressBarFocusRequestId else { return } + guard !isCommandPaletteVisibleForPanelWindow() else { return } guard lastHandledAddressBarFocusRequestId != requestId else { return } lastHandledAddressBarFocusRequestId = requestId panel.beginSuppressWebViewFocusForAddressBar() @@ -575,6 +843,7 @@ struct BrowserPanelView: View { private func autoFocusOmnibarIfBlank() { guard isFocused else { return } guard !addressBarFocused else { return } + guard !isCommandPaletteVisibleForPanelWindow() else { return } // If a test/automation explicitly focused WebKit, don't steal focus back. guard !panel.shouldSuppressOmnibarAutofocus() else { return } // If a real navigation is underway (e.g. open_browser https://...), don't steal focus. @@ -592,6 +861,13 @@ struct BrowserPanelView: View { } } + private func applyBrowserThemeModeSelection(_ mode: BrowserThemeMode) { + if browserThemeModeRaw != mode.rawValue { + browserThemeModeRaw = mode.rawValue + } + panel.setBrowserThemeMode(mode) + } + private func handleOmnibarTap() { onRequestPanelFocus() guard !addressBarFocused else { return } @@ -1942,6 +2218,13 @@ struct OmnibarSuggestion: Identifiable, Hashable { } } +func browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: Bool, + nextResponderIsOtherTextField: Bool +) -> Bool { + suppressWebViewFocus && !nextResponderIsOtherTextField +} + private final class OmnibarNativeTextField: NSTextField { var onPointerDown: (() -> Void)? var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? @@ -2054,6 +2337,29 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } } + private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool { + guard let window, let field = parentField else { return false } + let responder = window.firstResponder + + if let editor = responder as? NSTextView, + let delegateField = editor.delegate as? NSTextField { + return delegateField !== field + } + + if let textField = responder as? NSTextField { + return textField !== field + } + + return false + } + + private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool { + return browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: parent.shouldSuppressWebViewFocus(), + nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window) + ) + } + func controlTextDidBeginEditing(_ obj: Notification) { if !parent.isFocused { DispatchQueue.main.async { @@ -2066,15 +2372,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { func controlTextDidEndEditing(_ obj: Notification) { if parent.isFocused { - if parent.shouldSuppressWebViewFocus() { + if shouldReacquireFocusAfterEndEditing(window: parentField?.window) { guard pendingFocusRequest != true else { return } pendingFocusRequest = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.pendingFocusRequest = nil guard self.parent.isFocused else { return } - guard self.parent.shouldSuppressWebViewFocus() else { return } guard let field = self.parentField, let window = field.window else { return } + guard self.shouldReacquireFocusAfterEndEditing(window: window) else { + self.parent.onFieldLostFocus() + return + } // Check both the field itself AND its field editor (which becomes // the actual first responder when the text field is being edited). let fr = window.firstResponder @@ -2387,11 +2696,12 @@ private struct OmnibarSuggestionsView: View { let searchSuggestionsEnabled: Bool let onCommit: (OmnibarSuggestion) -> Void let onHighlight: (Int) -> Void + @Environment(\.colorScheme) private var colorScheme - // Keep radii below the smallest rendered heights so corners don't get - // auto-clamped and visually change as popup height changes. - private let popupCornerRadius: CGFloat = 16 - private let rowHighlightCornerRadius: CGFloat = 12 + // Keep radii below half of the smallest rendered heights so this keeps a + // squircle silhouette instead of auto-clamping into a capsule. + private let popupCornerRadius: CGFloat = 12 + private let rowHighlightCornerRadius: CGFloat = 9 private let singleLineRowHeight: CGFloat = 24 private let rowSpacing: CGFloat = 1 private let topInset: CGFloat = 3 @@ -2444,6 +2754,101 @@ private struct OmnibarSuggestionsView: View { contentHeight > maxPopupHeight } + private var listTextColor: Color { + switch colorScheme { + case .light: + return Color(nsColor: .labelColor) + case .dark: + return Color.white.opacity(0.9) + @unknown default: + return Color(nsColor: .labelColor) + } + } + + private var badgeTextColor: Color { + switch colorScheme { + case .light: + return Color(nsColor: .secondaryLabelColor) + case .dark: + return Color.white.opacity(0.72) + @unknown default: + return Color(nsColor: .secondaryLabelColor) + } + } + + private var badgeBackgroundColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.06) + case .dark: + return Color.white.opacity(0.08) + @unknown default: + return Color.black.opacity(0.06) + } + } + + private var rowHighlightColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.07) + case .dark: + return Color.white.opacity(0.12) + @unknown default: + return Color.black.opacity(0.07) + } + } + + private var popupOverlayGradientColors: [Color] { + switch colorScheme { + case .light: + return [ + Color.white.opacity(0.55), + Color.white.opacity(0.2), + ] + case .dark: + return [ + Color.black.opacity(0.26), + Color.black.opacity(0.14), + ] + @unknown default: + return [ + Color.white.opacity(0.55), + Color.white.opacity(0.2), + ] + } + } + + private var popupBorderGradientColors: [Color] { + switch colorScheme { + case .light: + return [ + Color.white.opacity(0.65), + Color.black.opacity(0.12), + ] + case .dark: + return [ + Color.white.opacity(0.22), + Color.white.opacity(0.06), + ] + @unknown default: + return [ + Color.white.opacity(0.65), + Color.black.opacity(0.12), + ] + } + } + + private var popupShadowColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.18) + case .dark: + return Color.black.opacity(0.45) + @unknown default: + return Color.black.opacity(0.18) + } + } + @ViewBuilder private var rowsView: some View { VStack(spacing: rowSpacing) { @@ -2457,18 +2862,18 @@ private struct OmnibarSuggestionsView: View { HStack(spacing: 6) { Text(item.listText) .font(.system(size: 11)) - .foregroundStyle(Color.white.opacity(0.9)) + .foregroundStyle(listTextColor) .lineLimit(1) .truncationMode(.tail) if let badge = item.trailingBadgeText { Text(badge) .font(.system(size: 9.5, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.72)) + .foregroundStyle(badgeTextColor) .padding(.horizontal, 6) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Color.white.opacity(0.08)) + .fill(badgeBackgroundColor) ) } Spacer(minLength: 0) @@ -2484,7 +2889,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous) .fill( idx == selectedIndex - ? Color.white.opacity(0.12) + ? rowHighlightColor : Color.clear ) ) @@ -2539,10 +2944,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .fill( LinearGradient( - colors: [ - Color.black.opacity(0.26), - Color.black.opacity(0.14), - ], + colors: popupOverlayGradientColors, startPoint: .top, endPoint: .bottom ) @@ -2553,18 +2955,16 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .stroke( LinearGradient( - colors: [ - Color.white.opacity(0.22), - Color.white.opacity(0.06), - ], + colors: popupBorderGradientColors, startPoint: .top, endPoint: .bottom ), lineWidth: 1 ) ) - .shadow(color: Color.black.opacity(0.45), radius: 20, y: 10) - .contentShape(Rectangle()) + .clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) + .shadow(color: popupShadowColor, radius: 20, y: 10) + .contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .accessibilityElement(children: .contain) .accessibilityRespondsToUserInteraction(true) .accessibilityIdentifier("BrowserOmnibarSuggestions") @@ -2621,6 +3021,27 @@ struct WebViewRepresentable: NSViewRepresentable { super.setFrameSize(newSize) onGeometryChanged?() } + + override func hitTest(_ point: NSPoint) -> NSView? { + if shouldPassThroughToSidebarResizer(at: point) { + return nil + } + return super.hitTest(point) + } + + private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { + // Pass through a narrow leading-edge band so the shared sidebar divider + // handle can receive hover/click even when WKWebView is attached here. + // Keeping this deterministic avoids flicker from dynamic left-edge scans. + guard point.x >= 0, point.x <= SidebarResizeInteraction.hitWidthPerSide else { + return false + } + guard let window, let contentView = window.contentView else { + return false + } + let hostRectInContent = contentView.convert(bounds, from: self) + return hostRectInContent.minX > 1 + } } #if DEBUG @@ -2842,6 +3263,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator: Coordinator, generation: Int ) { + let retryInterval: TimeInterval = 1.0 / 60.0 // Don't schedule multiple overlapping retries. guard coordinator.attachRetryWorkItem == nil else { return } @@ -2874,7 +3296,7 @@ struct WebViewRepresentable: NSViewRepresentable { // Be generous here: bonsplit structural updates can keep a representable // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) { scheduleAttachRetry( webView, panel: panel, @@ -2911,13 +3333,18 @@ struct WebViewRepresentable: NSViewRepresentable { } coordinator.attachRetryWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work) } func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView context.coordinator.panel = panel context.coordinator.webView = webView + Self.applyWebViewFirstResponderPolicy( + panel: panel, + webView: webView, + isPanelFocused: isPanelFocused + ) let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() if shouldUseWindowPortal { @@ -3165,6 +3592,26 @@ struct WebViewRepresentable: NSViewRepresentable { } } + private static func applyWebViewFirstResponderPolicy( + panel: BrowserPanel, + webView: WKWebView, + isPanelFocused: Bool + ) { + guard let cmuxWebView = webView as? CmuxWebView else { return } + let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() + if cmuxWebView.allowsFirstResponderAcquisition != next { +#if DEBUG + dlog( + "browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " + + "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + + "new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " + + "suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + } + cmuxWebView.allowsFirstResponderAcquisition = next + } + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachRetryWorkItem?.cancel() coordinator.attachRetryWorkItem = nil diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 08843c0f..bcd77ed2 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1,4 +1,6 @@ import AppKit +import Bonsplit +import ObjectiveC import WebKit /// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W), @@ -6,14 +8,124 @@ import WebKit /// key equivalents first so app-level shortcuts continue to work when WebKit is /// the first responder. final class CmuxWebView: WKWebView { - override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not - // route it through app/menu key equivalents, which can trigger unintended actions. - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 { + // Some sites/WebKit paths report middle-click link activations as + // WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local + // middle-click so navigation delegates can recover intent reliably. + private struct MiddleClickIntent { + let webViewID: ObjectIdentifier + let uptime: TimeInterval + } + + private static var lastMiddleClickIntent: MiddleClickIntent? + private static let middleClickIntentMaxAge: TimeInterval = 0.8 + + static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool { + guard let webView = webView as? CmuxWebView else { return false } + guard let intent = lastMiddleClickIntent else { return false } + + let age = ProcessInfo.processInfo.systemUptime - intent.uptime + if age > middleClickIntentMaxAge { + lastMiddleClickIntent = nil return false } + return intent.webViewID == ObjectIdentifier(webView) + } + + private static func recordMiddleClickIntent(for webView: CmuxWebView) { + lastMiddleClickIntent = MiddleClickIntent( + webViewID: ObjectIdentifier(webView), + uptime: ProcessInfo.processInfo.systemUptime + ) + } + + private final class ContextMenuFallbackBox: NSObject { + weak var target: AnyObject? + let action: Selector? + + init(target: AnyObject?, action: Selector?) { + self.target = target + self.action = action + } + } + + private static var contextMenuFallbackKey: UInt8 = 0 + + var onContextMenuDownloadStateChanged: ((Bool) -> Void)? + var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? + var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? + /// Guard against background panes stealing first responder (e.g. page autofocus). + /// BrowserPanelView updates this as pane focus state changes. + var allowsFirstResponderAcquisition: Bool = true + private var pointerFocusAllowanceDepth: Int = 0 + var allowsFirstResponderAcquisitionEffective: Bool { + allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0 + } + var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth } + + override func becomeFirstResponder() -> Bool { + guard allowsFirstResponderAcquisitionEffective else { +#if DEBUG + let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + dlog( + "browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " + + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)" + ) +#endif + return false + } + let result = super.becomeFirstResponder() + if result { + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self) + } +#if DEBUG + let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + dlog( + "browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " + + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)" + ) +#endif + return result + } + + /// Temporarily permits focus acquisition for explicit pointer-driven interactions + /// (mouse click into this webview) while keeping background autofocus blocked. + func withPointerFocusAllowance(_ body: () -> Void) { + pointerFocusAllowanceDepth += 1 +#if DEBUG + dlog( + "browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " + + "depth=\(pointerFocusAllowanceDepth)" + ) +#endif + defer { + pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1) +#if DEBUG + dlog( + "browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " + + "depth=\(pointerFocusAllowanceDepth)" + ) +#endif + } + body() + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.keyCode == 36 || event.keyCode == 76 { + // Always bypass app/menu key-equivalent routing for Return/Enter so WebKit + // receives the keyDown path used by form submission handlers. + return false + } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + // Menu/app shortcut routing is only needed for Command equivalents + // (New Tab, Close Tab, tab switching, split commands, etc). + guard flags.contains(.command) else { + return super.performKeyEquivalent(with: event) + } + // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true @@ -46,20 +158,48 @@ final class CmuxWebView: WKWebView { // NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so // bonsplit focus tracks which pane the user clicked in. override func mouseDown(with event: NSEvent) { +#if DEBUG + let windowNumber = window?.windowNumber ?? -1 + let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.focus.mouseDown web=\(ObjectIdentifier(self)) " + + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)" + ) +#endif NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self) - super.mouseDown(with: event) + withPointerFocusAllowance { + super.mouseDown(with: event) + } } - // MARK: - Mouse back/forward buttons & middle-click + // MARK: - Mouse back/forward buttons override func otherMouseDown(with event: NSEvent) { + if event.buttonNumber == 2 { + Self.recordMiddleClickIntent(for: self) + } +#if DEBUG + let point = convert(event.locationInWindow, from: nil) + let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue + dlog( + "browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " + + "clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))" + ) +#endif // Button 3 = back, button 4 = forward (multi-button mice like Logitech). // Consume the event so WebKit doesn't handle it. switch event.buttonNumber { case 3: +#if DEBUG + dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)") +#endif goBack() return case 4: +#if DEBUG + dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)") +#endif goForward() return default: @@ -69,25 +209,23 @@ final class CmuxWebView: WKWebView { } override func otherMouseUp(with event: NSEvent) { - // Middle-click (button 2) on a link opens it in a new tab. if event.buttonNumber == 2 { - let point = convert(event.locationInWindow, from: nil) - findLinkAtPoint(point) { [weak self] url in - guard let self, let url else { return } - NotificationCenter.default.post( - name: .webViewMiddleClickedLink, - object: self, - userInfo: ["url": url] - ) - } - return + Self.recordMiddleClickIntent(for: self) } +#if DEBUG + let point = convert(event.locationInWindow, from: nil) + let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue + dlog( + "browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " + + "clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))" + ) +#endif super.otherMouseUp(with: event) } - /// Use JavaScript to find the nearest anchor element at the given view-local point. + /// Finds the nearest anchor element at a given view-local point. + /// Used as a context-menu download fallback. private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) { - // WKWebView's coordinate system is flipped (origin top-left for web content). let flippedY = bounds.height - point.y let js = """ (() => { @@ -109,6 +247,327 @@ final class CmuxWebView: WKWebView { } } + // MARK: - Context menu download support + + /// The last context-menu point in view coordinates. + private var lastContextMenuPoint: NSPoint = .zero + /// Saved native WebKit action for "Download Image". + private var fallbackDownloadImageTarget: AnyObject? + private var fallbackDownloadImageAction: Selector? + /// Saved native WebKit action for "Download Linked File". + private var fallbackDownloadLinkedFileTarget: AnyObject? + private var fallbackDownloadLinkedFileAction: Selector? + + private func isDownloadableScheme(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() ?? "" + return scheme == "http" || scheme == "https" || scheme == "file" + } + + private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool { + guard target === self else { return false } + return action == #selector(contextMenuDownloadImage(_:)) + || action == #selector(contextMenuDownloadLinkedFile(_:)) + } + + private func resolveGoogleRedirectURL(_ url: URL) -> URL? { + guard let host = url.host?.lowercased(), host.contains("google.") else { return nil } + guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = comps.queryItems else { return nil } + let map = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name.lowercased(), $0.value ?? "") }) + let candidates = ["imgurl", "mediaurl", "url", "q"] + for key in candidates { + guard let raw = map[key], !raw.isEmpty, + let decoded = raw.removingPercentEncoding ?? raw as String?, + let candidate = URL(string: decoded), + isDownloadableScheme(candidate) else { + continue + } + return candidate + } + // Some links are wrapped as /url?... + if comps.path.lowercased() == "/url" { + for key in ["url", "q"] { + if let raw = map[key], let candidate = URL(string: raw), isDownloadableScheme(candidate) { + return candidate + } + } + } + return nil + } + + private func normalizedLinkedDownloadURL(_ url: URL) -> URL { + resolveGoogleRedirectURL(url) ?? url + } + + private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) { + let target = item.target as AnyObject? + let action = item.action + if isOurDownloadMenuAction(target: target, action: action) { + return + } + let box = ContextMenuFallbackBox(target: target, action: action) + objc_setAssociatedObject( + item, + &Self.contextMenuFallbackKey, + box, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + private func fallbackFromSender( + _ sender: Any?, + defaultAction: Selector?, + defaultTarget: AnyObject? + ) -> (action: Selector?, target: AnyObject?) { + if let item = sender as? NSMenuItem, + let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { + return (box.action, box.target) + } + return (defaultAction, defaultTarget) + } + + /// Resolve the topmost image URL near a point, accounting for overlay layers. + private func findImageURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) { + let flippedY = bounds.height - point.y + let js = """ + (() => { + const nodes = document.elementsFromPoint(\(point.x), \(flippedY)); + for (const start of nodes) { + let elChain = []; + let seen = new Set(); + let walk = (node) => { + let chain = []; + let localSeen = new Set(); + let visit = (n) => { + while (n && !localSeen.has(n)) { + localSeen.add(n); + chain.push(n); + n = n.parentElement; + } + }; + visit(node); + if (node && node.tagName === 'PICTURE') { + const img = node.querySelector('img'); + if (img) visit(img); + } + return chain; + }; + for (const el of walk(start)) { + if (!seen.has(el)) { + seen.add(el); + elChain.push(el); + } + } + + for (const el of elChain) { + if (el.tagName === 'IMG') { + if (el.currentSrc) return el.currentSrc; + if (el.src) return el.src; + } + if (el.tagName === 'PICTURE') { + const img = el.querySelector('img'); + if (img) { + if (img.currentSrc) return img.currentSrc; + if (img.src) return img.src; + } + } + } + } + return ''; + })(); + """ + evaluateJavaScript(js) { result, _ in + guard let src = result as? String, !src.isEmpty, + let url = URL(string: src) else { + completion(nil) + return + } + completion(url) + } + } + + /// Resolve the topmost link URL near a point, accounting for overlay layers. + private func findLinkURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) { + let flippedY = bounds.height - point.y + let js = """ + (() => { + const nodes = document.elementsFromPoint(\(point.x), \(flippedY)); + for (const start of nodes) { + let el = start; + let seen = new Set(); + let cur = (() => { + let n = start; + return n; + })(); + let walk = (node) => { + let chain = []; + while (node && !seen.has(node)) { + seen.add(node); + chain.push(node); + node = node.parentElement; + } + return chain; + }; + for (const n of walk(cur)) { + if (n.tagName === 'A' && n.href) return n.href; + } + } + return ''; + })(); + """ + evaluateJavaScript(js) { result, _ in + guard let href = result as? String, !href.isEmpty, + let url = URL(string: href) else { + completion(nil) + return + } + completion(url) + } + } + + private func resolveContextMenuLinkURL(at point: NSPoint, completion: @escaping (URL?) -> Void) { + if let contextMenuLinkURLProvider { + contextMenuLinkURLProvider(self, point, completion) + return + } + findLinkURLAtPoint(point, completion: completion) + } + + private func canOpenInDefaultBrowser(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() ?? "" + return scheme == "http" || scheme == "https" + } + + private func openContextMenuLinkInDefaultBrowser(_ url: URL) { + if let contextMenuDefaultBrowserOpener { + _ = contextMenuDefaultBrowserOpener(url) + return + } + _ = NSWorkspace.shared.open(url) + } + + private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) { + guard let action else { return } + // Guard against accidental self-recursion if fallback gets overwritten. + if target === self, + action == #selector(contextMenuDownloadImage(_:)) + || action == #selector(contextMenuDownloadLinkedFile(_:)) { + NSLog("CmuxWebView context fallback skipped (recursive self action)") + return + } + _ = NSApp.sendAction(action, to: target, from: sender) + } + + private func notifyContextMenuDownloadState(_ downloading: Bool) { + if Thread.isMainThread { + onContextMenuDownloadStateChanged?(downloading) + } else { + DispatchQueue.main.async { [weak self] in + self?.onContextMenuDownloadStateChanged?(downloading) + } + } + } + + private func downloadURLViaSession( + _ url: URL, + suggestedFilename: String?, + sender: Any?, + fallbackAction: Selector?, + fallbackTarget: AnyObject? + ) { + guard isDownloadableScheme(url) else { + runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + return + } + let scheme = url.scheme?.lowercased() ?? "" + notifyContextMenuDownloadState(true) + + if scheme == "file" { + DispatchQueue.main.async { + do { + let data = try Data(contentsOf: url) + let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines) + let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent) + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + // Download is already complete; we're now waiting for user save choice. + self.notifyContextMenuDownloadState(false) + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { return } + try? data.write(to: destURL, options: .atomic) + } + } catch { + self.notifyContextMenuDownloadState(false) + self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + } + } + return + } + + let cookieStore = configuration.websiteDataStore.httpCookieStore + cookieStore.getAllCookies { cookies in + var request = URLRequest(url: url) + request.httpMethod = "GET" + let cookieHeaders = HTTPCookie.requestHeaderFields(with: cookies) + for (key, value) in cookieHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + if let referer = self.url?.absoluteString, !referer.isEmpty { + request.setValue(referer, forHTTPHeaderField: "Referer") + } + if let ua = self.customUserAgent, !ua.isEmpty { + request.setValue(ua, forHTTPHeaderField: "User-Agent") + } + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + guard let data, error == nil else { + self.notifyContextMenuDownloadState(false) + self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + return + } + let filenameCandidate = suggestedFilename + ?? response?.suggestedFilename + ?? url.lastPathComponent + let saveName = filenameCandidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "download" : filenameCandidate + + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + // Download is already complete; we're now waiting for user save choice. + self.notifyContextMenuDownloadState(false) + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { return } + do { + try data.write(to: destURL, options: .atomic) + } catch { + self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + } + } + } + }.resume() + } + } + + private func startContextMenuDownload( + _ url: URL, + sender: Any?, + fallbackAction: Selector?, + fallbackTarget: AnyObject? + ) { + NSLog("CmuxWebView context download start: %@", url.absoluteString) + downloadURLViaSession( + url, + suggestedFilename: nil, + sender: sender, + fallbackAction: fallbackAction, + fallbackTarget: fallbackTarget + ) + } + // MARK: - Drag-and-drop passthrough // WKWebView inherently calls registerForDraggedTypes with public.text (and others). @@ -136,8 +595,23 @@ final class CmuxWebView: WKWebView { override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) + lastContextMenuPoint = convert(event.locationInWindow, from: nil) + var openLinkInsertionIndex: Int? + var hasDefaultBrowserOpenLinkItem = false + + for (index, item) in menu.items.enumerated() { + if !hasDefaultBrowserOpenLinkItem, + (item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:)) + || item.title == "Open Link in Default Browser") { + hasDefaultBrowserOpenLinkItem = true + } + + if openLinkInsertionIndex == nil, + (item.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" + || item.title == "Open Link") { + openLinkInsertionIndex = index + 1 + } - for item in menu.items { // Rename "Open Link in New Window" to "Open Link in New Tab". // The UIDelegate's createWebViewWith already handles the action // by opening the link as a new surface in the same pane. @@ -145,6 +619,183 @@ final class CmuxWebView: WKWebView { || item.title.contains("Open Link in New Window") { item.title = "Open Link in New Tab" } + + if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" + || item.title == "Download Image" { + NSLog("CmuxWebView context menu hook: download image") + captureFallbackForMenuItemIfNeeded(item) + // Keep global fallback as a secondary safety net. + if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { + fallbackDownloadImageTarget = box.target + fallbackDownloadImageAction = box.action + } else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) { + fallbackDownloadImageTarget = item.target as AnyObject? + fallbackDownloadImageAction = item.action + } + item.target = self + item.action = #selector(contextMenuDownloadImage(_:)) + } + + if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" + || item.title == "Download Linked File" { + NSLog("CmuxWebView context menu hook: download linked file") + captureFallbackForMenuItemIfNeeded(item) + // Keep global fallback as a secondary safety net. + if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { + fallbackDownloadLinkedFileTarget = box.target + fallbackDownloadLinkedFileAction = box.action + } else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) { + fallbackDownloadLinkedFileTarget = item.target as AnyObject? + fallbackDownloadLinkedFileAction = item.action + } + item.target = self + item.action = #selector(contextMenuDownloadLinkedFile(_:)) + } + } + + if let openLinkInsertionIndex, !hasDefaultBrowserOpenLinkItem { + let item = NSMenuItem( + title: "Open Link in Default Browser", + action: #selector(contextMenuOpenLinkInDefaultBrowser(_:)), + keyEquivalent: "" + ) + item.target = self + menu.insertItem(item, at: min(openLinkInsertionIndex, menu.items.count)) + } + } + + @objc private func contextMenuOpenLinkInDefaultBrowser(_ sender: Any?) { + _ = sender + let point = lastContextMenuPoint + resolveContextMenuLinkURL(at: point) { [weak self] url in + guard let self, let url, self.canOpenInDefaultBrowser(url) else { return } + self.openContextMenuLinkInDefaultBrowser(url) + } + } + + @objc private func contextMenuDownloadImage(_ sender: Any?) { + let point = lastContextMenuPoint + let fallback = fallbackFromSender( + sender, + defaultAction: fallbackDownloadImageAction, + defaultTarget: fallbackDownloadImageTarget + ) + findImageURLAtPoint(point) { [weak self] url in + guard let self else { return } + if let url { + let scheme = url.scheme?.lowercased() ?? "" + if scheme == "http" || scheme == "https" || scheme == "file" { + NSLog("CmuxWebView context download image URL: %@", url.absoluteString) + self.startContextMenuDownload( + url, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + return + } + } + + // Google Images and similar sites often expose blob:/data: image URLs. + // If image URL is not directly downloadable, fall back to the nearby link URL. + self.findLinkURLAtPoint(point) { linkURL in + guard let linkURL else { + NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender + ) + return + } + let linkScheme = linkURL.scheme?.lowercased() ?? "" + guard linkScheme == "http" || linkScheme == "https" || linkScheme == "file" else { + NSLog("CmuxWebView context download image: link URL not downloadable (%@), using fallback action", linkURL.absoluteString) + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender + ) + return + } + + NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString) + self.startContextMenuDownload( + linkURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + } + } + } + + @objc private func contextMenuDownloadLinkedFile(_ sender: Any?) { + let point = lastContextMenuPoint + let fallback = fallbackFromSender( + sender, + defaultAction: fallbackDownloadLinkedFileAction, + defaultTarget: fallbackDownloadLinkedFileTarget + ) + findLinkURLAtPoint(point) { [weak self] url in + guard let self else { return } + if let url { + let normalized = self.normalizedLinkedDownloadURL(url) + if self.isDownloadableScheme(normalized) { + NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + return + } + } + + // Fallback 1: image URL under cursor (useful on image-heavy result pages). + self.findImageURLAtPoint(point) { imageURL in + if let imageURL, self.isDownloadableScheme(imageURL) { + NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString) + self.startContextMenuDownload( + imageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + return + } + + // Fallback 2: simpler nearest-anchor lookup. + self.findLinkAtPoint(point) { fallbackURL in + guard let fallbackURL else { + NSLog("CmuxWebView context download linked file: URL nil, using fallback action") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender + ) + return + } + let normalized = self.normalizedLinkedDownloadURL(fallbackURL) + guard self.isDownloadableScheme(normalized) else { + NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString) + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender + ) + return + } + NSLog("CmuxWebView context download linked file fallback URL: %@ (normalized=%@)", fallbackURL.absoluteString, normalized.absoluteString) + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + } + } } } } diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 427d53c8..a0a719c4 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -7,6 +7,41 @@ public enum PanelType: String, Codable, Sendable { case browser } +enum FocusFlashCurve: Equatable { + case easeIn + case easeOut +} + +struct FocusFlashSegment: Equatable { + let delay: TimeInterval + let duration: TimeInterval + let targetOpacity: Double + let curve: FocusFlashCurve +} + +enum FocusFlashPattern { + static let values: [Double] = [0, 1, 0, 1, 0] + static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1] + static let duration: TimeInterval = 0.9 + static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn] + static let ringInset: Double = 6 + static let ringCornerRadius: Double = 10 + + static var segments: [FocusFlashSegment] { + let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) + return (0.. Void var body: some View { - ZStack(alignment: .topLeading) { - GhosttyTerminalView( - terminalSurface: panel.surface, - isActive: isFocused, - isVisibleInUI: isVisibleInUI, - portalZPriority: portalPriority, - showsInactiveOverlay: isSplit && !isFocused, - showsUnreadNotificationRing: hasUnreadNotification, - inactiveOverlayColor: appearance.unfocusedOverlayNSColor, - inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, - reattachToken: panel.viewReattachToken, - onFocus: { _ in onFocus() }, - onTriggerFlash: onTriggerFlash - ) - // Keep the NSViewRepresentable identity stable across bonsplit structural updates. - // This prevents transient teardown/recreate that can momentarily detach the hosted terminal view. - .id(panel.id) - .background(Color.clear) - - // Search overlay - if let searchState = panel.searchState { - SurfaceSearchOverlay( - surface: panel.surface, - searchState: searchState, - onClose: { - panel.searchState = nil - panel.hostedView.moveFocus() - } - ) - } - } + // Layering contract: terminal find UI is mounted in GhosttySurfaceScrollView (AppKit portal layer) + // via `searchState`. Rendering `SurfaceSearchOverlay` in this SwiftUI container can hide it. + GhosttyTerminalView( + terminalSurface: panel.surface, + isActive: isFocused, + isVisibleInUI: isVisibleInUI, + portalZPriority: portalPriority, + showsInactiveOverlay: isSplit && !isFocused, + showsUnreadNotificationRing: hasUnreadNotification, + inactiveOverlayColor: appearance.unfocusedOverlayNSColor, + inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, + searchState: panel.searchState, + reattachToken: panel.viewReattachToken, + onFocus: { _ in onFocus() }, + onTriggerFlash: onTriggerFlash + ) + // Keep the NSViewRepresentable identity stable across bonsplit structural updates. + // This prevents transient teardown/recreate that can momentarily detach the hosted terminal view. + .id(panel.id) + .background(Color.clear) } } diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index fdaa7b39..9ed42027 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -49,6 +49,7 @@ final class PortScanner: @unchecked Sendable { func registerTTY(workspaceId: UUID, panelId: UUID, ttyName: String) { queue.async { [self] in let key = PanelKey(workspaceId: workspaceId, panelId: panelId) + guard ttyNames[key] != ttyName else { return } ttyNames[key] = ttyName } } diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 2d181bcf..091870d8 100644 --- a/Sources/PostHogAnalytics.swift +++ b/Sources/PostHogAnalytics.swift @@ -39,8 +39,9 @@ final class PostHogAnalytics { PostHogSDK.shared.setup(config) - // Tag every event so PostHog can distinguish desktop from web. - PostHogSDK.shared.register(["platform": "cmuxterm"]) + // Tag every event so PostHog can distinguish desktop from web and + // break events down by released app version/build. + PostHogSDK.shared.register(Self.superProperties(infoDictionary: Bundle.main.infoDictionary ?? [:])) // The SDK automatically generates and persists an anonymous distinct ID. @@ -68,10 +69,14 @@ final class PostHogAnalytics { defaults.set(today, forKey: lastActiveDayUTCKey) - PostHogSDK.shared.capture("cmux_daily_active", properties: [ - "day_utc": today, - "reason": reason, - ]) + PostHogSDK.shared.capture( + "cmux_daily_active", + properties: Self.dailyActiveProperties( + dayUTC: today, + reason: reason, + infoDictionary: Bundle.main.infoDictionary ?? [:] + ) + ) // For DAU we care more about delivery than batching. PostHogSDK.shared.flush() @@ -90,4 +95,34 @@ final class PostHogAnalytics { formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: date) } + + nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] { + var properties: [String: Any] = ["platform": "cmuxterm"] + properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new } + return properties + } + + nonisolated static func dailyActiveProperties( + dayUTC: String, + reason: String, + infoDictionary: [String: Any] + ) -> [String: Any] { + var properties: [String: Any] = [ + "day_utc": dayUTC, + "reason": reason, + ] + properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new } + return properties + } + + nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] { + var properties: [String: Any] = [:] + if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty { + properties["app_version"] = value + } + if let value = infoDictionary["CFBundleVersion"] as? String, !value.isEmpty { + properties["app_build"] = value + } + return properties + } } diff --git a/Sources/SentryHelper.swift b/Sources/SentryHelper.swift new file mode 100644 index 00000000..9877a46c --- /dev/null +++ b/Sources/SentryHelper.swift @@ -0,0 +1,9 @@ +import Sentry + +/// Add a Sentry breadcrumb for user-action context in hang/crash reports. +func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) { + let crumb = Breadcrumb(level: .info, category: category) + crumb.message = message + crumb.data = data + SentrySDK.addBreadcrumb(crumb) +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift new file mode 100644 index 00000000..289909df --- /dev/null +++ b/Sources/SessionPersistence.swift @@ -0,0 +1,474 @@ +import CoreGraphics +import Foundation +import Bonsplit + +enum SessionSnapshotSchema { + static let currentVersion = 1 +} + +enum SessionPersistencePolicy { + static let defaultSidebarWidth: Double = 200 + static let minimumSidebarWidth: Double = 186 + static let maximumSidebarWidth: Double = 600 + static let minimumWindowWidth: Double = 300 + static let minimumWindowHeight: Double = 200 + static let autosaveInterval: TimeInterval = 8.0 + static let maxWindowsPerSnapshot: Int = 12 + static let maxWorkspacesPerWindow: Int = 128 + static let maxPanelsPerWorkspace: Int = 512 + static let maxScrollbackLinesPerTerminal: Int = 4000 + static let maxScrollbackCharactersPerTerminal: Int = 400_000 + + static func sanitizedSidebarWidth(_ candidate: Double?) -> Double { + let fallback = defaultSidebarWidth + guard let candidate, candidate.isFinite else { return fallback } + return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth) + } + + static func truncatedScrollback(_ text: String?) -> String? { + guard let text, !text.isEmpty else { return nil } + if text.count <= maxScrollbackCharactersPerTerminal { + return text + } + let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal) + let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart) + return String(text[safeStart...]) + } + + /// If truncation starts in the middle of an ANSI CSI escape sequence, advance + /// to the first printable character after that sequence to avoid replaying + /// malformed control bytes. + private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index { + guard initialStart > text.startIndex else { return initialStart } + let escape = "\u{001B}" + + guard let lastEscape = text[.. String.Index? { + var index = text.index(after: csiMarker) + while index < upperBound { + guard let scalar = text[index].unicodeScalars.first?.value else { + index = text.index(after: index) + continue + } + if scalar >= 0x40, scalar <= 0x7E { + return index + } + index = text.index(after: index) + } + return nil + } +} + +enum SessionRestorePolicy { + static func isRunningUnderAutomatedTests( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + if environment["CMUX_UI_TEST_MODE"] == "1" { + return true + } + if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) { + return true + } + if environment["XCTestConfigurationFilePath"] != nil { + return true + } + if environment["XCTestBundlePath"] != nil { + return true + } + if environment["XCTestSessionIdentifier"] != nil { + return true + } + if environment["XCInjectBundle"] != nil { + return true + } + if environment["XCInjectBundleInto"] != nil { + return true + } + if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { + return true + } + return false + } + + static func shouldAttemptRestore( + arguments: [String] = CommandLine.arguments, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" { + return false + } + if isRunningUnderAutomatedTests(environment: environment) { + return false + } + + let extraArgs = arguments + .dropFirst() + .filter { !$0.hasPrefix("-psn_") } + + // Any explicit launch argument is treated as an explicit open intent. + return extraArgs.isEmpty + } +} + +struct SessionRectSnapshot: Codable, Equatable, Sendable { + let x: Double + let y: Double + let width: Double + let height: Double + + init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + init(_ rect: CGRect) { + self.x = Double(rect.origin.x) + self.y = Double(rect.origin.y) + self.width = Double(rect.size.width) + self.height = Double(rect.size.height) + } + + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +struct SessionDisplaySnapshot: Codable, Sendable { + var displayID: UInt32? + var frame: SessionRectSnapshot? + var visibleFrame: SessionRectSnapshot? +} + +enum SessionSidebarSelection: String, Codable, Sendable, Equatable { + case tabs + case notifications + + init(selection: SidebarSelection) { + switch selection { + case .tabs: + self = .tabs + case .notifications: + self = .notifications + } + } + + var sidebarSelection: SidebarSelection { + switch self { + case .tabs: + return .tabs + case .notifications: + return .notifications + } + } +} + +struct SessionSidebarSnapshot: Codable, Sendable { + var isVisible: Bool + var selection: SessionSidebarSelection + var width: Double? +} + +struct SessionStatusEntrySnapshot: Codable, Sendable { + var key: String + var value: String + var icon: String? + var color: String? + var timestamp: TimeInterval +} + +struct SessionLogEntrySnapshot: Codable, Sendable { + var message: String + var level: String + var source: String? + var timestamp: TimeInterval +} + +struct SessionProgressSnapshot: Codable, Sendable { + var value: Double + var label: String? +} + +struct SessionGitBranchSnapshot: Codable, Sendable { + var branch: String + var isDirty: Bool +} + +struct SessionTerminalPanelSnapshot: Codable, Sendable { + var workingDirectory: String? + var scrollback: String? +} + +struct SessionBrowserPanelSnapshot: Codable, Sendable { + var urlString: String? + var shouldRenderWebView: Bool + var pageZoom: Double + var developerToolsVisible: Bool + var backHistoryURLStrings: [String]? + var forwardHistoryURLStrings: [String]? +} + +struct SessionPanelSnapshot: Codable, Sendable { + var id: UUID + var type: PanelType + var title: String? + var customTitle: String? + var directory: String? + var isPinned: Bool + var isManuallyUnread: Bool + var gitBranch: SessionGitBranchSnapshot? + var listeningPorts: [Int] + var ttyName: String? + var terminal: SessionTerminalPanelSnapshot? + var browser: SessionBrowserPanelSnapshot? +} + +enum SessionSplitOrientation: String, Codable, Sendable { + case horizontal + case vertical + + init(_ orientation: SplitOrientation) { + switch orientation { + case .horizontal: + self = .horizontal + case .vertical: + self = .vertical + } + } + + var splitOrientation: SplitOrientation { + switch self { + case .horizontal: + return .horizontal + case .vertical: + return .vertical + } + } +} + +struct SessionPaneLayoutSnapshot: Codable, Sendable { + var panelIds: [UUID] + var selectedPanelId: UUID? +} + +struct SessionSplitLayoutSnapshot: Codable, Sendable { + var orientation: SessionSplitOrientation + var dividerPosition: Double + var first: SessionWorkspaceLayoutSnapshot + var second: SessionWorkspaceLayoutSnapshot +} + +indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { + case pane(SessionPaneLayoutSnapshot) + case split(SessionSplitLayoutSnapshot) + + private enum CodingKeys: String, CodingKey { + case type + case pane + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "pane": + self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane)) + case "split": + self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split)) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .pane(let pane): + try container.encode("pane", forKey: .type) + try container.encode(pane, forKey: .pane) + case .split(let split): + try container.encode("split", forKey: .type) + try container.encode(split, forKey: .split) + } + } +} + +struct SessionWorkspaceSnapshot: Codable, Sendable { + var processTitle: String + var customTitle: String? + var customColor: String? + var isPinned: Bool + var currentDirectory: String + var focusedPanelId: UUID? + var layout: SessionWorkspaceLayoutSnapshot + var panels: [SessionPanelSnapshot] + var statusEntries: [SessionStatusEntrySnapshot] + var logEntries: [SessionLogEntrySnapshot] + var progress: SessionProgressSnapshot? + var gitBranch: SessionGitBranchSnapshot? +} + +struct SessionTabManagerSnapshot: Codable, Sendable { + var selectedWorkspaceIndex: Int? + var workspaces: [SessionWorkspaceSnapshot] +} + +struct SessionWindowSnapshot: Codable, Sendable { + var frame: SessionRectSnapshot? + var display: SessionDisplaySnapshot? + var tabManager: SessionTabManagerSnapshot + var sidebar: SessionSidebarSnapshot +} + +struct AppSessionSnapshot: Codable, Sendable { + var version: Int + var createdAt: TimeInterval + var windows: [SessionWindowSnapshot] +} + +enum SessionPersistenceStore { + static func load(fileURL: URL? = nil) -> AppSessionSnapshot? { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil } + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let decoder = JSONDecoder() + guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil } + guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } + guard !snapshot.windows.isEmpty else { return nil } + return snapshot + } + + @discardableResult + static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false } + let directory = fileURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(snapshot) + try data.write(to: fileURL, options: .atomic) + return true + } catch { + return false + } + } + + static func removeSnapshot(fileURL: URL? = nil) { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return } + try? FileManager.default.removeItem(at: fileURL) + } + + static func defaultSnapshotFileURL( + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + appSupportDirectory: URL? = nil + ) -> URL? { + let resolvedAppSupport: URL + if let appSupportDirectory { + resolvedAppSupport = appSupportDirectory + } else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + resolvedAppSupport = discovered + } else { + return nil + } + let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? bundleIdentifier! + : "com.cmuxterm.app" + let safeBundleId = bundleId.replacingOccurrences( + of: "[^A-Za-z0-9._-]", + with: "_", + options: .regularExpression + ) + return resolvedAppSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("session-\(safeBundleId).json", isDirectory: false) + } +} + +enum SessionScrollbackReplayStore { + static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE" + private static let directoryName = "cmux-session-scrollback" + private static let ansiEscape = "\u{001B}" + private static let ansiReset = "\u{001B}[0m" + + static func replayEnvironment( + for scrollback: String?, + tempDirectory: URL = FileManager.default.temporaryDirectory + ) -> [String: String] { + guard let replayText = normalizedScrollback(scrollback) else { return [:] } + guard let replayFileURL = writeReplayFile( + contents: replayText, + tempDirectory: tempDirectory + ) else { + return [:] + } + return [environmentKey: replayFileURL.path] + } + + private static func normalizedScrollback(_ scrollback: String?) -> String? { + guard let scrollback else { return nil } + guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil } + guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil } + return ansiSafeReplayText(truncated) + } + + /// Preserve ANSI color state safely across replay boundaries. + private static func ansiSafeReplayText(_ text: String) -> String { + guard text.contains(ansiEscape) else { return text } + var output = text + if !output.hasPrefix(ansiReset) { + output = ansiReset + output + } + if !output.hasSuffix(ansiReset) { + output += ansiReset + } + return output + } + + private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? { + guard let data = contents.data(using: .utf8) else { return nil } + let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true) + + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let fileURL = directory + .appendingPathComponent(UUID().uuidString, isDirectory: false) + .appendingPathExtension("txt") + try data.write(to: fileURL, options: .atomic) + return fileURL + } catch { + return nil + } + } +} diff --git a/Sources/SidebarSelectionState.swift b/Sources/SidebarSelectionState.swift index 6fed3117..78ea1ab5 100644 --- a/Sources/SidebarSelectionState.swift +++ b/Sources/SidebarSelectionState.swift @@ -2,6 +2,9 @@ import SwiftUI @MainActor final class SidebarSelectionState: ObservableObject { - @Published var selection: SidebarSelection = .tabs -} + @Published var selection: SidebarSelection + init(selection: SidebarSelection = .tabs) { + self.selection = selection + } +} diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 67b91682..a2586136 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -1,16 +1,17 @@ import Foundation +import Security enum SocketControlMode: String, CaseIterable, Identifiable { case off case cmuxOnly - /// Allow any local process to connect (no ancestry check). - /// Only accessible via CMUX_SOCKET_MODE=allowAll env var — not shown in the UI. + case automation + case password + /// Full open access (all local users/processes) with no ancestry or password gate. case allowAll var id: String { rawValue } - /// Cases shown in the Settings UI. `allowAll` is intentionally excluded. - static var uiCases: [SocketControlMode] { [.off, .cmuxOnly] } + static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] } var displayName: String { switch self { @@ -18,8 +19,12 @@ enum SocketControlMode: String, CaseIterable, Identifiable { return "Off" case .cmuxOnly: return "cmux processes only" + case .automation: + return "Automation mode" + case .password: + return "Password mode" case .allowAll: - return "Allow all processes" + return "Full open access" } } @@ -29,8 +34,126 @@ enum SocketControlMode: String, CaseIterable, Identifiable { return "Disable the local control socket." case .cmuxOnly: return "Only processes started inside cmux terminals can send commands." + case .automation: + return "Allow external local automation clients from this macOS user (no ancestry check)." + case .password: + return "Require socket authentication with a password stored in your keychain." case .allowAll: - return "Allow any local process to connect (no ancestry check)." + return "Allow any local process and user to connect with no auth. Unsafe." + } + } + + var socketFilePermissions: UInt16 { + switch self { + case .allowAll: + return 0o666 + case .off, .cmuxOnly, .automation, .password: + return 0o600 + } + } + + var requiresPasswordAuth: Bool { + self == .password + } +} + +enum SocketControlPasswordStore { + static let service = "com.cmuxterm.app.socket-control" + static let account = "local-socket-password" + + private static var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + static func configuredPassword( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty { + return envPassword + } + return try? loadPassword() + } + + static func hasConfiguredPassword( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + guard let configured = configuredPassword(environment: environment) else { return false } + return !configured.isEmpty + } + + static func verify( + password candidate: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + guard let expected = configuredPassword(environment: environment), !expected.isEmpty else { + return false + } + return expected == candidate + } + + static func loadPassword() throws -> String? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + guard let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + + static func savePassword(_ password: String) throws { + let normalized = password.trimmingCharacters(in: .newlines) + if normalized.isEmpty { + try clearPassword() + return + } + + let data = Data(normalized.utf8) + var lookup = baseQuery + lookup[kSecReturnData as String] = true + lookup[kSecMatchLimit as String] = kSecMatchLimitOne + + var existing: CFTypeRef? + let lookupStatus = SecItemCopyMatching(lookup as CFDictionary, &existing) + switch lookupStatus { + case errSecSuccess: + let attrsToUpdate: [String: Any] = [ + kSecValueData as String: data + ] + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary) + guard updateStatus == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus)) + } + case errSecItemNotFound: + var add = baseQuery + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(addStatus)) + } + default: + throw NSError(domain: NSOSStatusErrorDomain, code: Int(lookupStatus)) + } + } + + static func clearPassword() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } } } @@ -38,36 +161,131 @@ enum SocketControlMode: String, CaseIterable, Identifiable { struct SocketControlSettings { static let appStorageKey = "socketControlMode" static let legacyEnabledKey = "socketControlEnabled" + static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" + static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" - /// Map old persisted rawValues to the new enum. - static func migrateMode(_ raw: String) -> SocketControlMode { - switch raw { - case "off": return .off - case "cmuxOnly": return .cmuxOnly - case "allowAll": return .allowAll - // Legacy values: - case "notifications", "full": return .cmuxOnly - default: return defaultMode + private static func normalizeMode(_ raw: String) -> String { + raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "-", with: "") + } + + private static func parseMode(_ raw: String) -> SocketControlMode? { + switch normalizeMode(raw) { + case "off": + return .off + case "cmuxonly": + return .cmuxOnly + case "automation": + return .automation + case "password": + return .password + case "allowall", "openaccess", "fullopenaccess": + return .allowAll + // Legacy values from the old socket mode model. + case "notifications": + return .automation + case "full": + return .allowAll + default: + return nil } } + /// Map persisted values to the current enum values. + static func migrateMode(_ raw: String) -> SocketControlMode { + parseMode(raw) ?? defaultMode + } + static var defaultMode: SocketControlMode { return .cmuxOnly } - static func socketPath() -> String { - if let override = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"], !override.isEmpty { - return override - } + private static var isDebugBuild: Bool { #if DEBUG - return "/tmp/cmux-debug.sock" + true #else - return "/tmp/cmux.sock" + false #endif } - static func envOverrideEnabled() -> Bool? { - guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else { + static func socketPath( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + isDebugBuild: Bool = SocketControlSettings.isDebugBuild + ) -> String { + let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild) + + guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else { + return fallback + } + + if shouldHonorSocketPathOverride( + environment: environment, + bundleIdentifier: bundleIdentifier, + isDebugBuild: isDebugBuild + ) { + return override + } + + return fallback + } + + static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String { + if bundleIdentifier == "com.cmuxterm.app.nightly" { + return "/tmp/cmux-nightly.sock" + } + if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild { + return "/tmp/cmux-debug.sock" + } + if isStagingBundleIdentifier(bundleIdentifier) { + return "/tmp/cmux-staging.sock" + } + return "/tmp/cmux.sock" + } + + static func shouldHonorSocketPathOverride( + environment: [String: String], + bundleIdentifier: String?, + isDebugBuild: Bool + ) -> Bool { + if isTruthy(environment[allowSocketPathOverrideKey]) { + return true + } + if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) { + return true + } + return isDebugBuild + } + + static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier else { return false } + return bundleIdentifier == "com.cmuxterm.app.debug" + || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") + } + + static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier else { return false } + return bundleIdentifier == "com.cmuxterm.app.staging" + || bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.") + } + + static func isTruthy(_ raw: String?) -> Bool { + guard let raw else { return false } + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on": + return true + default: + return false + } + } + + static func envOverrideEnabled( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool? { + guard let raw = environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else { return nil } @@ -81,33 +299,30 @@ struct SocketControlSettings { } } - static func envOverrideMode() -> SocketControlMode? { - guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_MODE"], !raw.isEmpty else { + static func envOverrideMode( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> SocketControlMode? { + guard let raw = environment["CMUX_SOCKET_MODE"], !raw.isEmpty else { return nil } - let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - switch cleaned { - case "off": return .off - case "cmuxonly", "cmux_only", "cmux-only": return .cmuxOnly - case "allowall", "allow_all", "allow-all": return .allowAll - // Legacy env var values — map to allowAll so existing test scripts keep working - case "notifications", "full": return .allowAll - default: return SocketControlMode(rawValue: cleaned) - } + return parseMode(raw) } - static func effectiveMode(userMode: SocketControlMode) -> SocketControlMode { - if let overrideEnabled = envOverrideEnabled() { + static func effectiveMode( + userMode: SocketControlMode, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> SocketControlMode { + if let overrideEnabled = envOverrideEnabled(environment: environment) { if !overrideEnabled { return .off } - if let overrideMode = envOverrideMode() { + if let overrideMode = envOverrideMode(environment: environment) { return overrideMode } return userMode == .off ? .cmuxOnly : userMode } - if let overrideMode = envOverrideMode() { + if let overrideMode = envOverrideMode(environment: environment) { return overrideMode } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index af6b0d72..7dda1b50 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -17,15 +17,118 @@ private func portalDebugToken(_ view: NSView?) -> String { private func portalDebugFrame(_ rect: NSRect) -> String { String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) } + +private func portalDebugFrameInWindow(_ view: NSView?) -> String { + guard let view else { return "nil" } + guard view.window != nil else { return "no-window" } + return portalDebugFrame(view.convert(view.bounds, to: nil)) +} #endif final class WindowTerminalHostView: NSView { + private struct DividerRegion { + let rectInWindow: NSRect + let isVertical: Bool + } + + private enum DividerCursorKind: Equatable { + case vertical + case horizontal + + var cursor: NSCursor { + switch self { + case .vertical: return .resizeLeftRight + case .horizontal: return .resizeUpDown + } + } + } + override var isOpaque: Bool { false } + private static let sidebarLeadingEdgeEpsilon: CGFloat = 1 + private static let minimumVisibleLeadingContentWidth: CGFloat = 24 + private var cachedSidebarDividerX: CGFloat? + private var sidebarDividerMissCount = 0 + private var trackingArea: NSTrackingArea? + private var activeDividerCursorKind: DividerCursorKind? #if DEBUG private var lastDragRouteSignature: String? #endif + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + clearActiveDividerCursor(restoreArrow: false) + } + window?.invalidateCursorRects(for: self) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + window?.invalidateCursorRects(for: self) + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + window?.invalidateCursorRects(for: self) + } + + override func resetCursorRects() { + super.resetCursorRects() + guard let window, let rootView = window.contentView else { return } + var regions: [DividerRegion] = [] + Self.collectSplitDividerRegions(in: rootView, into: ®ions) + let expansion: CGFloat = 4 + for region in regions { + var rectInHost = convert(region.rectInWindow, from: nil) + rectInHost = rectInHost.insetBy( + dx: region.isVertical ? -expansion : 0, + dy: region.isVertical ? 0 : -expansion + ) + let clipped = rectInHost.intersection(bounds) + guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { continue } + addCursorRect(clipped, cursor: region.isVertical ? .resizeLeftRight : .resizeUpDown) + } + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let options: NSTrackingArea.Options = [ + .inVisibleRect, + .activeAlways, + .cursorUpdate, + .mouseMoved, + .mouseEnteredAndExited, + .enabledDuringMouseDrag, + ] + let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + addTrackingArea(next) + trackingArea = next + super.updateTrackingAreas() + } + + override func cursorUpdate(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateDividerCursor(at: point) + } + + override func mouseMoved(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + updateDividerCursor(at: point) + } + + override func mouseExited(with event: NSEvent) { + clearActiveDividerCursor(restoreArrow: true) + } + override func hitTest(_ point: NSPoint) -> NSView? { + updateDividerCursor(at: point) + + if shouldPassThroughToSidebarResizer(at: point) { + return nil + } + if shouldPassThroughToSplitDivider(at: point) { return nil } @@ -60,15 +163,95 @@ final class WindowTerminalHostView: NSView { return hitView === self ? nil : hitView } - private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { - guard let window else { return false } - let windowPoint = convert(point, to: nil) - guard let rootView = window.contentView else { return false } - return Self.containsSplitDivider(at: windowPoint, in: rootView) + private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { + // The sidebar resizer handle is implemented in SwiftUI. When terminals + // are portal-hosted, this AppKit host can otherwise sit above the handle + // and steal hover/mouse events. + let visibleHostedViews = subviews.compactMap { $0 as? GhosttySurfaceScrollView } + .filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 } + + // If content is flush to the leading edge, sidebar is effectively hidden. + // In that state, treating any internal split edge as a sidebar divider + // steals split-divider cursor/drag behavior. + let hasLeadingContent = visibleHostedViews.contains { + $0.frame.minX <= Self.sidebarLeadingEdgeEpsilon + && $0.frame.maxX > Self.minimumVisibleLeadingContentWidth + } + if hasLeadingContent { + if cachedSidebarDividerX != nil { + sidebarDividerMissCount += 1 + if sidebarDividerMissCount >= 2 { + cachedSidebarDividerX = nil + sidebarDividerMissCount = 0 + } + } + return false + } + + // Ignore transient 0-origin hosts while layouts churn (e.g. workspace + // creation/switching). They can temporarily report minX=0 and would + // otherwise clear divider pass-through, causing hover flicker. + let dividerCandidates = visibleHostedViews + .map(\.frame.minX) + .filter { $0 > Self.sidebarLeadingEdgeEpsilon } + if let leftMostEdge = dividerCandidates.min() { + cachedSidebarDividerX = leftMostEdge + sidebarDividerMissCount = 0 + } else if cachedSidebarDividerX != nil { + // Keep cache briefly for layout churn, but clear if we miss repeatedly + // so stale divider positions don't steal pointer routing. + sidebarDividerMissCount += 1 + if sidebarDividerMissCount >= 4 { + cachedSidebarDividerX = nil + sidebarDividerMissCount = 0 + } + } + + guard let dividerX = cachedSidebarDividerX else { + return false + } + + let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide + let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide + return point.x >= regionMinX && point.x <= regionMaxX } - private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool { - guard !view.isHidden else { return false } + private func updateDividerCursor(at point: NSPoint) { + if shouldPassThroughToSidebarResizer(at: point) { + clearActiveDividerCursor(restoreArrow: false) + return + } + + guard let nextKind = splitDividerCursorKind(at: point) else { + clearActiveDividerCursor(restoreArrow: true) + return + } + activeDividerCursorKind = nextKind + nextKind.cursor.set() + } + + private func clearActiveDividerCursor(restoreArrow: Bool) { + guard activeDividerCursorKind != nil else { return } + window?.invalidateCursorRects(for: self) + activeDividerCursorKind = nil + if restoreArrow { + NSCursor.arrow.set() + } + } + + private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? { + guard let window else { return nil } + let windowPoint = convert(point, to: nil) + guard let rootView = window.contentView else { return nil } + return Self.dividerCursorKind(at: windowPoint, in: rootView) + } + + private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { + splitDividerCursorKind(at: point) != nil + } + + private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? { + guard !view.isHidden else { return nil } if let splitView = view as? NSSplitView { let pointInSplit = splitView.convert(windowPoint, from: nil) @@ -83,7 +266,10 @@ final class WindowTerminalHostView: NSView { let thickness = splitView.dividerThickness let dividerRect: NSRect if splitView.isVertical { - guard first.width > 1, second.width > 1 else { continue } + // Keep divider hit-testing active even when one side is nearly collapsed, + // so users can drag the divider back out from the border. + // But ignore transient states where both panes are effectively 0-width. + guard first.width > 1 || second.width > 1 else { continue } let x = max(0, first.maxX) dividerRect = NSRect( x: x, @@ -92,7 +278,8 @@ final class WindowTerminalHostView: NSView { height: splitView.bounds.height ) } else { - guard first.height > 1, second.height > 1 else { continue } + // Same behavior for horizontal splits with a near-zero-height pane. + guard first.height > 1 || second.height > 1 else { continue } let y = max(0, first.maxY) dividerRect = NSRect( x: 0, @@ -103,19 +290,54 @@ final class WindowTerminalHostView: NSView { } let expandedDividerRect = dividerRect.insetBy(dx: -expansion, dy: -expansion) if expandedDividerRect.contains(pointInSplit) { - return true + return splitView.isVertical ? .vertical : .horizontal } } } } for subview in view.subviews.reversed() { - if containsSplitDivider(at: windowPoint, in: subview) { - return true + if let kind = dividerCursorKind(at: windowPoint, in: subview) { + return kind } } - return false + return nil + } + + private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) { + guard !view.isHidden else { return } + + if let splitView = view as? NSSplitView { + let dividerCount = max(0, splitView.arrangedSubviews.count - 1) + for dividerIndex in 0.. 1 || second.width > 1 else { continue } + let x = max(0, first.maxX) + dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height) + } else { + guard first.height > 1 || second.height > 1 else { continue } + let y = max(0, first.maxY) + dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness) + } + let dividerRectInWindow = splitView.convert(dividerRect, to: nil) + guard dividerRectInWindow.width > 0, dividerRectInWindow.height > 0 else { continue } + result.append( + DividerRegion( + rectInWindow: dividerRectInWindow, + isVertical: splitView.isVertical + ) + ) + } + } + + for subview in view.subviews { + collectSplitDividerRegions(in: subview, into: &result) + } } #if DEBUG @@ -178,14 +400,157 @@ final class WindowTerminalHostView: NSView { #endif } +private final class SplitDividerOverlayView: NSView { + private struct DividerSegment { + let rect: NSRect + let color: NSColor + let isVertical: Bool + } + + override var isOpaque: Bool { false } + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard let window, let rootView = window.contentView else { return } + + var dividerSegments: [DividerSegment] = [] + collectDividerSegments(in: rootView, into: ÷rSegments) + guard !dividerSegments.isEmpty else { return } + let hostedFrames = hostedFramesLikelyToOccludeDividers() + let visibleSegments = dividerSegments.filter { shouldRenderOverlay(for: $0, hostedFrames: hostedFrames) } + guard !visibleSegments.isEmpty else { return } + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + + // Keep separators visible above portal-hosted surfaces while matching each split view's + // native divider color (avoids visible color shifts at tiny pane sizes). + for segment in visibleSegments where segment.rect.intersects(dirtyRect) { + segment.color.setFill() + let rect = segment.rect + let pixelAligned = NSRect( + x: floor(rect.origin.x), + y: floor(rect.origin.y), + width: max(1, round(rect.size.width)), + height: max(1, round(rect.size.height)) + ) + NSBezierPath(rect: pixelAligned).fill() + } + } + + private func collectDividerSegments(in view: NSView, into result: inout [DividerSegment]) { + guard !view.isHidden else { return } + + if let splitView = view as? NSSplitView { + let dividerCount = max(0, splitView.arrangedSubviews.count - 1) + let dividerColor = overlayDividerColor(for: splitView) + for dividerIndex in 0.. [NSRect] { + guard let hostView = superview else { return [] } + return hostView.subviews.compactMap { subview -> NSRect? in + guard let hosted = subview as? GhosttySurfaceScrollView else { return nil } + guard !hosted.isHidden, hosted.window != nil else { return nil } + return hosted.frame + } + } + + private func shouldRenderOverlay(for segment: DividerSegment, hostedFrames: [NSRect]) -> Bool { + // Draw only when a hosted surface actually intrudes across the divider centerline. + // This preserves tiny-pane visibility fixes without darkening regular dividers. + let axisEpsilon: CGFloat = 0.01 + let axis = segment.isVertical ? segment.rect.midX : segment.rect.midY + let extentRect = segment.rect.insetBy( + dx: segment.isVertical ? 0 : -1, + dy: segment.isVertical ? -1 : 0 + ) + + for frame in hostedFrames where frame.intersects(extentRect) { + if segment.isVertical { + if frame.minX < axis - axisEpsilon && frame.maxX > axis + axisEpsilon { + return true + } + } else if frame.minY < axis - axisEpsilon && frame.maxY > axis + axisEpsilon { + return true + } + } + return false + } + + private func overlayDividerColor(for splitView: NSSplitView) -> NSColor { + let divider = splitView.dividerColor.usingColorSpace(.deviceRGB) ?? splitView.dividerColor + let alpha = divider.alphaComponent + guard alpha < 0.999 else { return divider } + + guard let bgColor = splitView.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)), + let bgRGB = bgColor.usingColorSpace(.deviceRGB) else { + return divider + } + + let opaqueBG = bgRGB.withAlphaComponent(1) + let opaqueDivider = divider.withAlphaComponent(1) + return opaqueBG.blended(withFraction: alpha, of: opaqueDivider) ?? divider + } +} + @MainActor final class WindowTerminalPortal: NSObject { + private static let tinyHideThreshold: CGFloat = 1 + private static let minimumRevealWidth: CGFloat = 24 + private static let minimumRevealHeight: CGFloat = 18 + private weak var window: NSWindow? private let hostView = WindowTerminalHostView(frame: .zero) + private let dividerOverlayView = SplitDividerOverlayView(frame: .zero) private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] +#if DEBUG + private var lastLoggedBonsplitContainerSignature: String? +#endif private struct Entry { weak var hostedView: GhosttySurfaceScrollView? @@ -200,11 +565,155 @@ final class WindowTerminalPortal: NSObject { init(window: NSWindow) { self.window = window super.init() - hostView.wantsLayer = false + hostView.wantsLayer = true + hostView.layer?.masksToBounds = true + hostView.postsFrameChangedNotifications = true + hostView.postsBoundsChangedNotifications = true hostView.translatesAutoresizingMaskIntoConstraints = false + dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true + dividerOverlayView.autoresizingMask = [.width, .height] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.frameDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.boundsDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeLayoutHierarchy() { + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + _ = synchronizeHostFrameToReference() + } + + @discardableResult + private func synchronizeHostFrameToReference() -> Bool { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return false + } + let frameInContainer = container.convert(reference.bounds, from: reference) + let hasFiniteFrame = + frameInContainer.origin.x.isFinite && + frameInContainer.origin.y.isFinite && + frameInContainer.size.width.isFinite && + frameInContainer.size.height.isFinite + guard hasFiniteFrame else { return false } + + if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostView.frame = frameInContainer + CATransaction.commit() +#if DEBUG + dlog( + "portal.hostFrame.update host=\(portalDebugToken(hostView)) " + + "frame=\(portalDebugFrame(frameInContainer))" + ) +#endif + } + return frameInContainer.width > 1 && frameInContainer.height > 1 + } + + private func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() + synchronizeAllHostedViews(excluding: nil) + + // During live resize, AppKit can deliver frame churn where host/container geometry + // settles a tick before the terminal's own scroll/surface hierarchy. Force a final + // in-place geometry + surface refresh for all visible entries in this window. + for entry in entriesByHostedId.values { + guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue } + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() + } + } + + private func ensureDividerOverlayOnTop() { + if dividerOverlayView.superview !== hostView { + dividerOverlayView.frame = hostView.bounds + hostView.addSubview(dividerOverlayView, positioned: .above, relativeTo: nil) + } else if hostView.subviews.last !== dividerOverlayView { + hostView.addSubview(dividerOverlayView, positioned: .above, relativeTo: nil) + } + + if !Self.rectApproximatelyEqual(dividerOverlayView.frame, hostView.bounds) { + dividerOverlayView.frame = hostView.bounds + } + dividerOverlayView.needsDisplay = true + } + @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } @@ -239,6 +748,10 @@ final class WindowTerminalPortal: NSObject { container.addSubview(overlay, positioned: .above, relativeTo: hostView) } + synchronizeLayoutHierarchy() + _ = synchronizeHostFrameToReference() + ensureDividerOverlayOnTop() + return true } @@ -266,13 +779,32 @@ final class WindowTerminalPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { abs(lhs.origin.x - rhs.origin.x) <= epsilon && abs(lhs.origin.y - rhs.origin.y) <= epsilon && abs(lhs.size.width - rhs.size.width) <= epsilon && abs(lhs.size.height - rhs.size.height) <= epsilon } + private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool { guard let viewIndex = container.subviews.firstIndex(of: view), let referenceIndex = container.subviews.firstIndex(of: reference) else { @@ -281,6 +813,87 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } +#if DEBUG + private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? { + var current: NSView? = anchorView + while let view = current { + let className = NSStringFromClass(type(of: view)) + if className.contains("PaneDragContainerView") || className.contains("Bonsplit") { + return view + } + current = view.superview + } + return installedReferenceView + } + + private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) { + guard let container = nearestBonsplitContainer(from: anchorView) else { return } + let containerFrame = container.convert(container.bounds, to: nil) + let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))" + guard signature != lastLoggedBonsplitContainerSignature else { return } + lastLoggedBonsplitContainerSignature = signature + + let containerClass = NSStringFromClass(type(of: container)) + dlog( + "portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " + + "class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " + + "host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))" + ) + } +#endif + + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. + /// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when + /// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective + /// visible rect that should drive portal geometry. + private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect { + var frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + var current = anchorView.superview + while let ancestor = current { + let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil) + let finiteAncestorBounds = + ancestorBoundsInWindow.origin.x.isFinite && + ancestorBoundsInWindow.origin.y.isFinite && + ancestorBoundsInWindow.size.width.isFinite && + ancestorBoundsInWindow.size.height.isFinite + if finiteAncestorBounds { + frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow) + if frameInWindow.isNull { return .zero } + } + if ancestor === installedReferenceView { break } + current = ancestor.superview + } + return frameInWindow + } + + private func seededFrameInHost(for anchorView: NSView) -> NSRect? { + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite + guard hasFiniteFrame else { return nil } + + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + if hasFiniteHostBounds { + let clampedFrame = frameInHost.intersection(hostBounds) + if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 { + return clampedFrame + } + } + + return frameInHost + } + func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } if let anchor = entry.anchorView { @@ -321,6 +934,12 @@ final class WindowTerminalPortal: NSObject { entriesByHostedId[hostedId] = entry } + func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool { + guard let entry = entriesByHostedId[hostedId], + let boundAnchor = entry.anchorView else { return false } + return boundAnchor === anchorView + } + func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -372,6 +991,32 @@ final class WindowTerminalPortal: NSObject { } #endif + _ = synchronizeHostFrameToReference() + + // Seed frame/bounds before entering the window so a freshly reparented + // surface doesn't do a transient 800x600 size update on viewDidMoveToWindow. + if let seededFrame = seededFrameInHost(for: anchorView), + seededFrame.width > 0, + seededFrame.height > 0 { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = seededFrame + hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size) + CATransaction.commit() + } else { + // If anchor geometry is still unsettled, keep this hidden/zero-sized until + // synchronizeHostedView resolves a valid target frame on the next layout tick. + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = .zero + hostedView.bounds = .zero + CATransaction.commit() + hostedView.isHidden = true + } + // Keep inner scroll/surface geometry in sync with the seeded outer frame + // before the hosted view enters a window. + hostedView.reconcileGeometryNow() + if hostedView.superview !== hostView { #if DEBUG dlog( @@ -394,11 +1039,16 @@ final class WindowTerminalPortal: NSObject { hostView.addSubview(hostedView, positioned: .above, relativeTo: nil) } + ensureDividerOverlayOnTop() + synchronizeHostedView(withId: hostedId) + scheduleDeferredFullSynchronizeAll() pruneDeadEntries() } func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let anchorId = ObjectIdentifier(anchorView) let primaryHostedId = hostedByAnchorId[anchorId] @@ -425,6 +1075,7 @@ final class WindowTerminalPortal: NSObject { private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) { guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let hostedIds = Array(entriesByHostedId.keys) for hostedId in hostedIds { @@ -467,62 +1118,162 @@ final class WindowTerminalPortal: NSObject { return } - let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) +#if DEBUG + logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView) +#endif + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 + if !hostBoundsReady { +#if DEBUG + dlog( + "portal.sync.defer hosted=\(portalDebugToken(hostedView)) " + + "reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " + + "anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" + ) +#endif + hostedView.isHidden = true + scheduleDeferredFullSynchronizeAll() + return + } let hasFiniteFrame = frameInHost.origin.x.isFinite && frameInHost.origin.y.isFinite && frameInHost.size.width.isFinite && frameInHost.size.height.isFinite + let clampedFrame = frameInHost.intersection(hostBounds) + let hasVisibleIntersection = + !clampedFrame.isNull && + clampedFrame.width > 1 && + clampedFrame.height > 1 + let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) - let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 - let outsideHostBounds = !frameInHost.intersects(hostView.bounds) + let tinyFrame = + targetFrame.width <= Self.tinyHideThreshold || + targetFrame.height <= Self.tinyHideThreshold + let revealReadyForDisplay = + targetFrame.width >= Self.minimumRevealWidth && + targetFrame.height >= Self.minimumRevealHeight + let outsideHostBounds = !hasVisibleIntersection let shouldHide = !entry.visibleInUI || anchorHidden || tinyFrame || !hasFiniteFrame || outsideHostBounds + let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay let oldFrame = hostedView.frame #if DEBUG + let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) + if frameWasClamped { + dlog( + "portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) " + + "raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" + ) + } let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame if collapsedToTiny { dlog( "portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } else if restoredFromTiny { dlog( "portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } #endif - if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + + // Hide before updating the frame when this entry should not be visible. + // This avoids a one-frame flash of unrendered terminal background when a portal + // briefly transitions through offscreen/tiny geometry during rapid split churn. + if shouldHide, !hostedView.isHidden { +#if DEBUG + dlog( + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" + ) +#endif + hostedView.isHidden = true + } + + if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) - hostedView.frame = frameInHost + hostedView.frame = targetFrame CATransaction.commit() + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() + } - if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || - abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { - hostedView.reconcileGeometryNow() + if hasFiniteFrame { + let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) + if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.bounds = expectedBounds + CATransaction.commit() } } - if hostedView.isHidden != shouldHide { + if shouldDeferReveal { +#if DEBUG + if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + dlog( + "portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " + + "frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))" + ) + } +#endif + } + + if !shouldHide, hostedView.isHidden, revealReadyForDisplay { #if DEBUG dlog( - "portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + - "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + - "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" ) #endif - hostedView.isHidden = shouldHide + hostedView.isHidden = false + // A reveal can happen without any frame delta (same targetFrame), which means the + // normal frame-change refresh path won't run. Nudge geometry + redraw so newly + // revealed terminals don't sit on a stale/blank IOSurface until later focus churn. + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() } + +#if DEBUG + dlog( + "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) " + + "old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " + + "target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " + + "hostBounds=\(portalDebugFrame(hostBounds))" + ) +#endif + + ensureDividerOverlayOnTop() } private func pruneDeadEntries() { @@ -555,6 +1306,7 @@ final class WindowTerminalPortal: NSObject { } func tearDown() { + removeGeometryObservers() for hostedId in Array(entriesByHostedId.keys) { detachHostedView(withId: hostedId) } @@ -721,6 +1473,15 @@ enum TerminalWindowPortalRegistry { portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI) } + static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool { + let hostedId = ObjectIdentifier(hostedView) + guard let window = anchorView.window else { return false } + let windowId = ObjectIdentifier(window) + guard hostedToWindowId[hostedId] == windowId, + let portal = portalsByWindowId[windowId] else { return false } + return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView) + } + static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? { let portal = portal(for: window) return portal.viewAtWindowPoint(windowPoint) diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 0fc1c4e1..94fae950 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -8,6 +8,8 @@ class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver private var installCancellable: AnyCancellable? + private var attemptInstallCancellable: AnyCancellable? + private var didObserveAttemptUpdateProgress: Bool = false private var noUpdateDismissCancellable: AnyCancellable? private var noUpdateDismissWorkItem: DispatchWorkItem? private var readyCheckWorkItem: DispatchWorkItem? @@ -46,6 +48,7 @@ class UpdateController { deinit { installCancellable?.cancel() + attemptInstallCancellable?.cancel() noUpdateDismissCancellable?.cancel() noUpdateDismissWorkItem?.cancel() readyCheckWorkItem?.cancel() @@ -107,6 +110,35 @@ class UpdateController { } } + /// Check for updates and auto-confirm install if one is found. + func attemptUpdate() { + stopAttemptUpdateMonitoring() + didObserveAttemptUpdateProgress = false + + attemptInstallCancellable = viewModel.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + + if state.isInstallable || !state.isIdle { + self.didObserveAttemptUpdateProgress = true + } + + if case .updateAvailable = state { + UpdateLogStore.shared.append("attemptUpdate auto-confirming available update") + state.confirm() + return + } + + guard self.didObserveAttemptUpdateProgress, !state.isInstallable else { + return + } + self.stopAttemptUpdateMonitoring() + } + + checkForUpdates() + } + /// Check for updates (used by the menu item). @objc func checkForUpdates() { UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))") @@ -175,6 +207,12 @@ class UpdateController { return true } + private func stopAttemptUpdateMonitoring() { + attemptInstallCancellable?.cancel() + attemptInstallCancellable = nil + didObserveAttemptUpdateProgress = false + } + private func installNoUpdateDismissObserver() { noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState) .receive(on: DispatchQueue.main) diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 32e2304c..dfcd457c 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -80,7 +80,9 @@ extension UpdateDriver: SPUUpdaterDelegate { } } + @MainActor func updaterWillRelaunchApplication(_ updater: SPUUpdater) { + AppDelegate.shared?.persistSessionForUpdateRelaunch() TerminalController.shared.stop() NSApp.invalidateRestorableState() for window in NSApp.windows { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index ff73c91a..84ac40d3 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -333,7 +333,7 @@ struct TitlebarControlsView: View { .foregroundColor(.white) .frame(width: config.badgeSize, height: config.badgeSize) .background( - Circle().fill(Color.accentColor) + Circle().fill(cmuxAccentColor()) ) .offset(x: config.badgeOffset.width, y: config.badgeOffset.height) } @@ -905,11 +905,11 @@ private struct NotificationPopoverRow: View { Button(action: onOpen) { HStack(alignment: .top, spacing: 10) { Circle() - .fill(notification.isRead ? Color.clear : Color.accentColor) + .fill(notification.isRead ? Color.clear : cmuxAccentColor()) .frame(width: 8, height: 8) .overlay( Circle() - .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) ) .padding(.top, 6) diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 8aa275af..dd8a6697 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -132,7 +132,7 @@ class UpdateViewModel: ObservableObject { case .checking: return .secondary case .updateAvailable: - return .accentColor + return cmuxAccentColor() case .downloading, .extracting, .installing: return .secondary case .notFound: @@ -147,7 +147,7 @@ class UpdateViewModel: ObservableObject { case .permissionRequest: return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) case .updateAvailable: - return .accentColor + return cmuxAccentColor() case .notFound: return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue) case .error: diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index e534e1bc..a468c088 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -1,6 +1,246 @@ import AppKit +import Bonsplit import SwiftUI +private func windowDragHandleFormatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) +} + +/// Runs the same action macOS titlebars use for double-click: +/// zoom by default, or minimize when the user preference is set. +@discardableResult +func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool { + guard let window else { return false } + + let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] + if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() { + switch action { + case "minimize": + window.miniaturize(nil) + return true + case "none": + return false + case "maximize", "zoom": + window.zoom(nil) + return true + default: + break + } + } + + if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool, + miniaturizeOnDoubleClick { + window.miniaturize(nil) + return true + } + + window.zoom(nil) + return true +} + +private var windowDragSuppressionDepthKey: UInt8 = 0 + +func beginWindowDragSuppression(window: NSWindow?) -> Int? { + guard let window else { return nil } + let current = windowDragSuppressionDepth(window: window) + let next = current + 1 + objc_setAssociatedObject( + window, + &windowDragSuppressionDepthKey, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return next +} + +@discardableResult +func endWindowDragSuppression(window: NSWindow?) -> Int { + guard let window else { return 0 } + let current = windowDragSuppressionDepth(window: window) + let next = max(0, current - 1) + if next == 0 { + objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } else { + objc_setAssociatedObject( + window, + &windowDragSuppressionDepthKey, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + return next +} + +func windowDragSuppressionDepth(window: NSWindow?) -> Int { + guard let window, + let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else { + return 0 + } + return value.intValue +} + +func isWindowDragSuppressed(window: NSWindow?) -> Bool { + windowDragSuppressionDepth(window: window) > 0 +} + +@discardableResult +func clearWindowDragSuppression(window: NSWindow?) -> Int { + guard let window else { return 0 } + var depth = windowDragSuppressionDepth(window: window) + while depth > 0 { + depth = endWindowDragSuppression(window: window) + } + return depth +} + +/// Temporarily enables window movability for explicit drag-handle drags, then +/// restores the previous movability state after `body` finishes. +@discardableResult +func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? { + guard let window else { + body() + return nil + } + + let previousMovableState = window.isMovable + if !previousMovableState { + window.isMovable = true + } + defer { + if window.isMovable != previousMovableState { + window.isMovable = previousMovableState + } + } + + body() + return previousMovableState +} + +private enum WindowDragHandleHitTestState { + static var isResolvingTopHit = false +} + +/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty +/// titlebar space. Treat those as pass-through so explicit sibling checks decide. +func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool { + let className = String(describing: type(of: view)) + if className.contains("HostContainerView") + || className.contains("AppKitWindowHostingView") + || className.contains("NSHostingView") { + return true + } + if let window = view.window, view === window.contentView { + return true + } + return false +} + +/// Returns whether the titlebar drag handle should capture a hit at `point`. +/// We only claim the hit when no sibling view already handles it, so interactive +/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. +func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool { + if isWindowDragSuppressed(window: dragHandleView.window) { + // Recover from stale suppression if a prior interaction missed cleanup. + // We only keep suppression active while the left mouse button is down. + if (NSEvent.pressedMouseButtons & 0x1) == 0 { + let clearedDepth = clearWindowDragSuppression(window: dragHandleView.window) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))" + ) + #endif + } else { + #if DEBUG + let depth = windowDragSuppressionDepth(window: dragHandleView.window) + dlog( + "titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))" + ) + #endif + return false + } + } + + guard dragHandleView.bounds.contains(point) else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))") + #endif + return false + } + + guard let superview = dragHandleView.superview else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))") + #endif + return true + } + + if let window = dragHandleView.window, + let contentView = window.contentView, + !WindowDragHandleHitTestState.isResolvingTopHit { + let pointInWindow = dragHandleView.convert(point, to: nil) + let pointInContent = contentView.convert(pointInWindow, from: nil) + + WindowDragHandleHitTestState.isResolvingTopHit = true + let topHit = contentView.hitTest(pointInContent) + WindowDragHandleHitTestState.isResolvingTopHit = false + + if let topHit { + let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView) + let topHitBelongsToTitlebarOverlay = topHit === superview || topHit.isDescendant(of: superview) + let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) inTitlebarOverlay=\(topHitBelongsToTitlebarOverlay) passiveHost=\(isPassiveHostHit)" + ) + #endif + if ownsTopHit { + return true + } + // Underlay content can transiently overlap titlebar space (notably browser + // chrome/webview layers). Only let top-hits block capture when they belong + // to this titlebar overlay stack. + if topHitBelongsToTitlebarOverlay && !isPassiveHostHit { + return false + } + } + } + + #if DEBUG + let siblingCount = superview.subviews.count + #endif + + for sibling in superview.subviews.reversed() { + guard sibling !== dragHandleView else { continue } + guard !sibling.isHidden, sibling.alphaValue > 0 else { continue } + + let pointInSibling = dragHandleView.convert(point, to: sibling) + if let hitView = sibling.hitTest(pointInSibling) { + let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView) + if passiveHostHit { + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true" + ) + #endif + continue + } + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false" + ) + #endif + return false + } + } + + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)") + #endif + return true +} + /// A transparent view that enables dragging the window when clicking in empty titlebar space. /// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content /// (e.g. sidebar tab reordering) don't move the whole window. @@ -14,8 +254,55 @@ struct WindowDragHandleView: NSViewRepresentable { } private final class DraggableView: NSView { - override var mouseDownCanMoveWindow: Bool { true } - override func hitTest(_ point: NSPoint) -> NSView? { self } + override var mouseDownCanMoveWindow: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)" + ) + #endif + return shouldCapture ? self : nil + } + + override func mouseDown(with event: NSEvent) { + #if DEBUG + let point = convert(event.locationInWindow, from: nil) + let depth = windowDragSuppressionDepth(window: window) + dlog( + "titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)" + ) + #endif + + if event.clickCount >= 2 { + let handled = performStandardTitlebarDoubleClick(window: window) + #if DEBUG + dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)") + #endif + if handled { + return + } + } + + guard !isWindowDragSuppressed(window: window) else { + #if DEBUG + dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed") + #endif + return + } + + if let window { + let previousMovableState = withTemporaryWindowMovableEnabled(window: window) { + window.performDrag(with: event) + } + #if DEBUG + let restored = previousMovableState.map { String($0) } ?? "nil" + dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)") + #endif + } else { + super.mouseDown(with: event) + } + } } } - diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index ec34dd1b..3e058a47 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -9,10 +9,27 @@ struct WorkspaceContentView: View { let isWorkspaceVisible: Bool let isWorkspaceInputActive: Bool let workspacePortalPriority: Int - @State private var config = GhosttyConfig.load() + let onThemeRefreshRequest: (( + _ reason: String, + _ backgroundEventId: UInt64?, + _ backgroundSource: String?, + _ notificationPayloadHex: String? + ) -> Void)? + @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore + static func panelVisibleInUI( + isWorkspaceVisible: Bool, + isSelectedInPane: Bool, + isFocused: Bool + ) -> Bool { + guard isWorkspaceVisible else { return false } + // During pane/tab reparenting, Bonsplit can transiently report selected=false + // for the currently focused panel. Keep focused content visible to avoid blank frames. + return isSelectedInPane || isFocused + } + var body: some View { let appearance = PanelAppearance.fromConfig(config) let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || @@ -41,8 +58,15 @@ struct WorkspaceContentView: View { if let panel = workspace.panel(for: tab.id) { let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = isWorkspaceVisible && isSelectedInPane - let hasUnreadNotification = notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id) + let isVisibleInUI = Self.panelVisibleInUI( + isWorkspaceVisible: isWorkspaceVisible, + isSelectedInPane: isSelectedInPane, + isFocused: isFocused + ) + let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), + isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) + ) PanelContentView( panel: panel, isFocused: isFocused, @@ -58,7 +82,7 @@ struct WorkspaceContentView: View { // indicator and where keyboard input/flash-focus actually lands. guard isWorkspaceInputActive else { return } guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id) + workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) }, onRequestPanelFocus: { guard isWorkspaceInputActive else { return } @@ -84,7 +108,7 @@ struct WorkspaceContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { syncBonsplitNotificationBadges() - workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) + refreshGhosttyAppearanceConfig(reason: "onAppear") } .onChange(of: notificationStore.notifications) { _, _ in syncBonsplitNotificationBadges() @@ -93,18 +117,28 @@ struct WorkspaceContentView: View { syncBonsplitNotificationBadges() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in - refreshGhosttyAppearanceConfig() + refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload") } - .onChange(of: colorScheme) { _, _ in + .onChange(of: colorScheme) { oldValue, newValue in // Keep split overlay color/opacity in sync with light/dark theme transitions. - refreshGhosttyAppearanceConfig() + refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)") } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in - if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor { - workspace.applyGhosttyChrome(backgroundColor: backgroundColor) - } else { - workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) - } + let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value + let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil" + logTheme( + "theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) + // Payload ordering can lag across rapid config/theme updates. + // Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned + // with Ghostty's current runtime theme. + refreshGhosttyAppearanceConfig( + reason: "ghosttyDefaultBackgroundDidChange", + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex + ) } } @@ -138,10 +172,95 @@ struct WorkspaceContentView: View { } } - private func refreshGhosttyAppearanceConfig() { - let next = GhosttyConfig.load() - config = next - workspace.applyGhosttyChrome(from: next) + static func resolveGhosttyAppearanceConfig( + reason: String = "unspecified", + backgroundOverride: NSColor? = nil, + loadConfig: () -> GhosttyConfig = GhosttyConfig.load, + defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor } + ) -> GhosttyConfig { + var next = loadConfig() + let loadedBackgroundHex = next.backgroundColor.hexString() + let defaultBackgroundHex: String + let resolvedBackground: NSColor + + if let backgroundOverride { + resolvedBackground = backgroundOverride + defaultBackgroundHex = "skipped" + } else { + let fallback = defaultBackground() + resolvedBackground = fallback + defaultBackgroundHex = fallback.hexString() + } + + next.backgroundColor = resolvedBackground + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")" + ) + } + return next + } + + private func refreshGhosttyAppearanceConfig( + reason: String, + backgroundOverride: NSColor? = nil, + backgroundEventId: UInt64? = nil, + backgroundSource: String? = nil, + notificationPayloadHex: String? = nil + ) { + let previousBackgroundHex = config.backgroundColor.hexString() + let next = Self.resolveGhosttyAppearanceConfig( + reason: reason, + backgroundOverride: backgroundOverride + ) + let eventLabel = backgroundEventId.map(String.init) ?? "nil" + let sourceLabel = backgroundSource ?? "nil" + let payloadLabel = notificationPayloadHex ?? "nil" + let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString() + let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear" + logTheme( + "theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" + ) + withTransaction(Transaction(animation: nil)) { + config = next + if shouldRequestTitlebarRefresh { + onThemeRefreshRequest?( + reason, + backgroundEventId, + backgroundSource, + notificationPayloadHex + ) + } + } + if !shouldRequestTitlebarRefresh { + logTheme( + "theme refresh titlebar-skip workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString())" + ) + } + logTheme( + "theme refresh config-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) configBg=\(config.backgroundColor.hexString())" + ) + let chromeReason = + "refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)" + workspace.applyGhosttyChrome(from: next, reason: chromeReason) + if let terminalPanel = workspace.focusedTerminalPanel { + terminalPanel.applyWindowBackgroundIfActive() + logTheme( + "theme refresh terminal-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) panel=\(workspace.focusedPanelId?.uuidString ?? "nil")" + ) + } else { + logTheme( + "theme refresh terminal-skipped workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) focusedPanel=\(workspace.focusedPanelId?.uuidString ?? "nil")" + ) + } + logTheme( + "theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" + ) + } + + private func logTheme(_ message: String) { + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground(message) } } @@ -174,6 +293,8 @@ extension WorkspaceContentView { struct EmptyPanelView: View { @ObservedObject var workspace: Workspace let paneId: PaneID + @AppStorage(KeyboardShortcutSettings.Action.newSurface.defaultsKey) private var newSurfaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.openBrowser.defaultsKey) private var openBrowserShortcutData = Data() private struct ShortcutHint: View { let text: String @@ -208,6 +329,49 @@ struct EmptyPanelView: View { _ = workspace.newBrowserSurface(inPane: paneId) } + private var newSurfaceShortcut: StoredShortcut { + decodeShortcut(from: newSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.newSurface.defaultShortcut) + } + + private var openBrowserShortcut: StoredShortcut { + decodeShortcut(from: openBrowserShortcutData, fallback: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut) + } + + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { + guard !data.isEmpty, + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return fallback + } + return shortcut + } + + @ViewBuilder + private func emptyPaneActionButton( + title: String, + systemImage: String, + shortcut: StoredShortcut, + action: @escaping () -> Void + ) -> some View { + if let key = shortcut.keyEquivalent { + Button(action: action) { + HStack(spacing: 10) { + Label(title, systemImage: systemImage) + ShortcutHint(text: shortcut.displayString) + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(key, modifiers: shortcut.eventModifiers) + } else { + Button(action: action) { + HStack(spacing: 10) { + Label(title, systemImage: systemImage) + ShortcutHint(text: shortcut.displayString) + } + } + .buttonStyle(.borderedProminent) + } + } + var body: some View { VStack(spacing: 16) { Image(systemName: "terminal.fill") @@ -219,27 +383,19 @@ struct EmptyPanelView: View { .foregroundStyle(.secondary) HStack(spacing: 12) { - Button { - createTerminal() - } label: { - HStack(spacing: 10) { - Label("Terminal", systemImage: "terminal.fill") - ShortcutHint(text: "⌘T") - } - } - .buttonStyle(.borderedProminent) - .keyboardShortcut("t", modifiers: [.command]) + emptyPaneActionButton( + title: "Terminal", + systemImage: "terminal.fill", + shortcut: newSurfaceShortcut, + action: createTerminal + ) - Button { - createBrowser() - } label: { - HStack(spacing: 10) { - Label("Browser", systemImage: "globe") - ShortcutHint(text: "⌘⇧L") - } - } - .buttonStyle(.borderedProminent) - .keyboardShortcut("l", modifiers: [.command, .shift]) + emptyPaneActionButton( + title: "Browser", + systemImage: "globe", + shortcut: openBrowserShortcut, + action: createBrowser + ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 24841d43..f857ffa0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -13,6 +13,15 @@ struct cmuxApp: App { @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue + @AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.newWindow.defaultsKey) private var newWindowShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.showNotifications.defaultsKey) private var showNotificationsShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.nextSurface.defaultsKey) private var nextSurfaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.prevSurface.defaultsKey) private var prevSurfaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey) private var nextWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) @@ -21,6 +30,8 @@ struct cmuxApp: App { private var showBrowserJavaScriptConsoleShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -200,7 +211,7 @@ struct cmuxApp: App { GhosttyApp.shared.openConfigurationInTextEdit() } Button("Reload Configuration") { - GhosttyApp.shared.reloadConfiguration() + GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") } .keyboardShortcut(",", modifiers: [.command, .shift]) Divider() @@ -257,11 +268,11 @@ struct cmuxApp: App { Divider() } - Button("Show Notifications") { + splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) { showNotificationsPopover() } - Button("Jump to Latest Unread") { + splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) { appDelegate.jumpToLatestUnread() } .disabled(!snapshot.hasUnreadNotifications) @@ -287,6 +298,10 @@ struct cmuxApp: App { appDelegate.openDebugScrollbackTab(nil) } + Button("Open Workspaces for All Tab Colors") { + appDelegate.openDebugColorComparisonWorkspaces(nil) + } + Divider() Menu("Debug Windows") { Button("Debug Window Controls…") { @@ -337,18 +352,42 @@ struct cmuxApp: App { // New tab commands CommandGroup(replacing: .newItem) { - Button("New Window") { + splitCommandButton(title: "New Window", shortcut: newWindowMenuShortcut) { appDelegate.openNewMainWindow(nil) } - .keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Workspace") { - (AppDelegate.shared?.tabManager ?? tabManager).addTab() + splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) { + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { +#if DEBUG + FocusLogStore.shared.append( + "cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil" + ) +#endif + appDelegate.openNewMainWindow(nil) + } + } else { + activeTabManager.addTab() + } } } // Close tab/workspace CommandGroup(after: .newItem) { + Button("Go to Workspace or Tab…") { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + } + .keyboardShortcut("p", modifiers: [.command]) + + Button("Command Palette…") { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + } + .keyboardShortcut("p", modifiers: [.command, .shift]) + + Divider() + // Terminal semantics: // Cmd+W closes the focused tab (with confirmation if needed). If this is the last // tab in the last workspace, it closes the window. @@ -359,105 +398,111 @@ struct cmuxApp: App { // Cmd+Shift+W closes the current workspace (with confirmation if needed). If this // is the last workspace, it closes the window. - Button("Close Workspace") { + splitCommandButton(title: "Close Workspace", shortcut: closeWorkspaceMenuShortcut) { closeTabOrWindow() } - .keyboardShortcut("w", modifiers: [.command, .shift]) + + Button("Reopen Closed Browser Panel") { + _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() + } + .keyboardShortcut("t", modifiers: [.command, .shift]) } // Find CommandGroup(after: .textEditing) { Menu("Find") { Button("Find…") { - (AppDelegate.shared?.tabManager ?? tabManager).startSearch() + activeTabManager.startSearch() } .keyboardShortcut("f", modifiers: .command) Button("Find Next") { - (AppDelegate.shared?.tabManager ?? tabManager).findNext() + activeTabManager.findNext() } .keyboardShortcut("g", modifiers: .command) Button("Find Previous") { - (AppDelegate.shared?.tabManager ?? tabManager).findPrevious() + activeTabManager.findPrevious() } .keyboardShortcut("g", modifiers: [.command, .shift]) Divider() Button("Hide Find Bar") { - (AppDelegate.shared?.tabManager ?? tabManager).hideFind() + activeTabManager.hideFind() } .keyboardShortcut("f", modifiers: [.command, .shift]) - .disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible)) + .disabled(!(activeTabManager.isFindVisible)) Divider() Button("Use Selection for Find") { - (AppDelegate.shared?.tabManager ?? tabManager).searchSelection() + activeTabManager.searchSelection() } .keyboardShortcut("e", modifiers: .command) - .disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind)) + .disabled(!(activeTabManager.canUseSelectionForFind)) } } // Tab navigation CommandGroup(after: .toolbar) { - Button("Toggle Sidebar") { - sidebarState.toggle() + splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) { + if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true { + sidebarState.toggle() + } } Divider() - Button("Next Surface") { - (AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface() + splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) { + activeTabManager.selectNextSurface() } - Button("Previous Surface") { - (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface() + splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) { + activeTabManager.selectPreviousSurface() } Button("Back") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack() + activeTabManager.focusedBrowserPanel?.goBack() } .keyboardShortcut("[", modifiers: .command) Button("Forward") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward() + activeTabManager.focusedBrowserPanel?.goForward() } .keyboardShortcut("]", modifiers: .command) Button("Reload Page") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload() + activeTabManager.focusedBrowserPanel?.reload() } .keyboardShortcut("r", modifiers: .command) splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if !manager.toggleDeveloperToolsFocusedBrowser() { NSSound.beep() } } splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if !manager.showJavaScriptConsoleFocusedBrowser() { NSSound.beep() } } Button("Zoom In") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() + _ = activeTabManager.zoomInFocusedBrowser() } .keyboardShortcut("=", modifiers: .command) Button("Zoom Out") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser() + _ = activeTabManager.zoomOutFocusedBrowser() } .keyboardShortcut("-", modifiers: .command) Button("Actual Size") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser() + _ = activeTabManager.resetZoomFocusedBrowser() } .keyboardShortcut("0", modifiers: .command) @@ -465,12 +510,16 @@ struct cmuxApp: App { BrowserHistoryStore.shared.clearHistory() } - Button("Next Workspace") { - (AppDelegate.shared?.tabManager ?? tabManager).selectNextTab() + splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) { + activeTabManager.selectNextTab() } - Button("Previous Workspace") { - (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab() + splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) { + activeTabManager.selectPreviousTab() + } + + splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) { + _ = AppDelegate.shared?.promptRenameSelectedWorkspace() } Divider() @@ -496,7 +545,7 @@ struct cmuxApp: App { // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in Button("Workspace \(number)") { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) { manager.selectTab(at: targetIndex) } @@ -506,11 +555,11 @@ struct cmuxApp: App { Divider() - Button("Jump to Latest Unread") { + splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) { AppDelegate.shared?.jumpToLatestUnread() } - Button("Show Notifications") { + splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) { showNotificationsPopover() } } @@ -569,6 +618,54 @@ struct cmuxApp: App { decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut) } + private var toggleSidebarMenuShortcut: StoredShortcut { + decodeShortcut(from: toggleSidebarShortcutData, fallback: KeyboardShortcutSettings.Action.toggleSidebar.defaultShortcut) + } + + private var newWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut(from: newWorkspaceShortcutData, fallback: KeyboardShortcutSettings.Action.newTab.defaultShortcut) + } + + private var newWindowMenuShortcut: StoredShortcut { + decodeShortcut(from: newWindowShortcutData, fallback: KeyboardShortcutSettings.Action.newWindow.defaultShortcut) + } + + private var showNotificationsMenuShortcut: StoredShortcut { + decodeShortcut( + from: showNotificationsShortcutData, + fallback: KeyboardShortcutSettings.Action.showNotifications.defaultShortcut + ) + } + + private var jumpToUnreadMenuShortcut: StoredShortcut { + decodeShortcut( + from: jumpToUnreadShortcutData, + fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut + ) + } + + private var nextSurfaceMenuShortcut: StoredShortcut { + decodeShortcut(from: nextSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.nextSurface.defaultShortcut) + } + + private var prevSurfaceMenuShortcut: StoredShortcut { + decodeShortcut(from: prevSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.prevSurface.defaultShortcut) + } + + private var nextWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: nextWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + ) + } + + private var prevWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: prevWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + ) + } + private var splitDownMenuShortcut: StoredShortcut { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } @@ -601,10 +698,30 @@ struct cmuxApp: App { ) } + private var renameWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: renameWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + ) + } + + private var closeWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: closeWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } + private var activeTabManager: TabManager { + AppDelegate.shared?.synchronizeActiveMainWindowContext( + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) ?? tabManager + } + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { guard !data.isEmpty, let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { @@ -642,61 +759,25 @@ struct cmuxApp: App { @ViewBuilder private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View { - if let key = keyEquivalent(for: shortcut) { + if let key = shortcut.keyEquivalent { Button(title, action: action) - .keyboardShortcut(key, modifiers: eventModifiers(for: shortcut)) + .keyboardShortcut(key, modifiers: shortcut.eventModifiers) } else { Button(title, action: action) } } - private func keyEquivalent(for shortcut: StoredShortcut) -> KeyEquivalent? { - switch shortcut.key { - case "←": - return .leftArrow - case "→": - return .rightArrow - case "↑": - return .upArrow - case "↓": - return .downArrow - case "\t": - return .tab - default: - let lowered = shortcut.key.lowercased() - guard lowered.count == 1, let character = lowered.first else { return nil } - return KeyEquivalent(character) - } - } - - private func eventModifiers(for shortcut: StoredShortcut) -> EventModifiers { - var modifiers: EventModifiers = [] - if shortcut.command { - modifiers.insert(.command) - } - if shortcut.shift { - modifiers.insert(.shift) - } - if shortcut.option { - modifiers.insert(.option) - } - if shortcut.control { - modifiers.insert(.control) - } - return modifiers - } - private func closePanelOrWindow() { if let window = NSApp.keyWindow, window.identifier?.rawValue == "cmux.settings" { window.performClose(nil) return } - (AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation() + activeTabManager.closeCurrentPanelWithConfirmation() } private func closeTabOrWindow() { - (AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation() + activeTabManager.closeCurrentTabWithConfirmation() } private func showNotificationsPopover() { @@ -1178,6 +1259,8 @@ private enum DebugWindowConfigSnapshot { sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000")) sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) + sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout)) + sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue)) shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX))) shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX))) @@ -1274,6 +1357,8 @@ private struct DebugWindowControlsView: View { @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @@ -1286,6 +1371,17 @@ private struct DebugWindowControlsView: View { BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { @@ -1351,6 +1447,22 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } + GroupBox("Active Workspace Indicator") { + VStack(alignment: .leading, spacing: 8) { + Picker("Style", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + .pickerStyle(.menu) + + Button("Reset Indicator Style") { + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + } + } + .padding(.top, 2) + } + GroupBox("Titlebar Spacing") { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { @@ -1744,6 +1856,7 @@ private struct SidebarDebugView: View { @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 + @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX @@ -1751,6 +1864,19 @@ private struct SidebarDebugView: View { @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + + private var selectedSidebarIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } var body: some View { ScrollView { @@ -1852,6 +1978,27 @@ private struct SidebarDebugView: View { .padding(.top, 2) } + GroupBox("Active Workspace Indicator") { + VStack(alignment: .leading, spacing: 8) { + Picker("Style", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + } + .padding(.top, 2) + } + + GroupBox("Workspace Metadata") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout) + Text("When enabled, each branch appears on its own line in the sidebar.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.top, 2) + } + HStack(spacing: 12) { Button("Reset Tint") { sidebarTintOpacity = 0.62 @@ -1869,6 +2016,9 @@ private struct SidebarDebugView: View { Button("Reset Hints") { resetShortcutHintOffsets() } + Button("Reset Active Indicator") { + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + } } Button("Copy Config") { @@ -1935,6 +2085,8 @@ private struct SidebarDebugView: View { sidebarTintHex=\(sidebarTintHex) sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) + sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout) + sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle) shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) @@ -2398,9 +2550,37 @@ enum AppearanceSettings { } } +enum QuitWarningSettings { + static let warnBeforeQuitKey = "warnBeforeQuitShortcut" + static let defaultWarnBeforeQuit = true + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: warnBeforeQuitKey) == nil { + return defaultWarnBeforeQuit + } + return defaults.bool(forKey: warnBeforeQuitKey) + } + + static func setEnabled(_ isEnabled: Bool, defaults: UserDefaults = .standard) { + defaults.set(isEnabled, forKey: warnBeforeQuitKey) + } +} + +enum CommandPaletteRenameSelectionSettings { + static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus" + static let defaultSelectAllOnFocus = true + + static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: selectAllOnFocusKey) == nil { + return defaultSelectAllOnFocus + } + return defaults.bool(forKey: selectAllOnFocusKey) + } +} + enum ClaudeCodeIntegrationSettings { static let hooksEnabledKey = "claudeCodeHooksEnabled" - static let defaultHooksEnabled = false + static let defaultHooksEnabled = true static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: hooksEnabledKey) == nil { @@ -2422,28 +2602,91 @@ struct SettingsView: View { @AppStorage("cmuxPortRange") private var cmuxPortRange = 10 @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled + @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled + @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue + @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @State private var settingsTitleLeadingInset: CGFloat = 92 @State private var showClearBrowserHistoryConfirmation = false + @State private var showOpenAccessConfirmation = false + @State private var pendingOpenAccessMode: SocketControlMode? @State private var browserHistoryEntryCount: Int = 0 @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText + @State private var socketPasswordDraft = "" + @State private var socketPasswordStatusMessage: String? + @State private var socketPasswordStatusIsError = false + @State private var workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() + @State private var workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + private var selectedSocketControlMode: SocketControlMode { SocketControlSettings.migrateMode(socketControlMode) } + private var selectedBrowserThemeMode: BrowserThemeMode { + BrowserThemeSettings.mode(for: browserThemeMode) + } + + private var browserThemeModeSelection: Binding { + Binding( + get: { browserThemeMode }, + set: { newValue in + browserThemeMode = BrowserThemeSettings.mode(for: newValue).rawValue + } + ) + } + + private var socketModeSelection: Binding { + Binding( + get: { socketControlMode }, + set: { newValue in + let normalized = SocketControlSettings.migrateMode(newValue) + if normalized == .allowAll && selectedSocketControlMode != .allowAll { + pendingOpenAccessMode = normalized + showOpenAccessConfirmation = true + return + } + socketControlMode = normalized.rawValue + if normalized != .password { + socketPasswordStatusMessage = nil + socketPasswordStatusIsError = false + } + } + ) + } + + private var hasSocketPasswordConfigured: Bool { + SocketControlPasswordStore.hasConfiguredPassword() + } + private var browserHistorySubtitle: String { switch browserHistoryEntryCount { case 0: @@ -2465,6 +2708,37 @@ struct SettingsView: View { return Double(min(max(reveal, 0), 1)) } + private func saveSocketPassword() { + let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + socketPasswordStatusMessage = "Enter a password first." + socketPasswordStatusIsError = true + return + } + + do { + try SocketControlPasswordStore.savePassword(trimmed) + socketPasswordDraft = "" + socketPasswordStatusMessage = "Password saved to keychain." + socketPasswordStatusIsError = false + } catch { + socketPasswordStatusMessage = "Failed to save password (\(error.localizedDescription))." + socketPasswordStatusIsError = true + } + } + + private func clearSocketPassword() { + do { + try SocketControlPasswordStore.clearPassword() + socketPasswordDraft = "" + socketPasswordStatusMessage = "Password cleared." + socketPasswordStatusIsError = false + } catch { + socketPasswordStatusMessage = "Failed to clear password (\(error.localizedDescription))." + socketPasswordStatusIsError = true + } + } + var body: some View { ZStack(alignment: .top) { ScrollView { @@ -2518,6 +2792,140 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + + SettingsCardDivider() + + SettingsCardRow( + "Warn Before Quit", + subtitle: warnBeforeQuitShortcut + ? "Show a confirmation before quitting with Cmd+Q." + : "Cmd+Q quits immediately without confirmation." + ) { + Toggle("", isOn: $warnBeforeQuitShortcut) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Rename Selects Existing Name", + subtitle: commandPaletteRenameSelectAllOnFocus + ? "Command Palette rename starts with all text selected." + : "Command Palette rename keeps the caret at the end." + ) { + Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Sidebar Branch Layout", + subtitle: sidebarBranchVerticalLayout + ? "Vertical: each branch appears on its own line." + : "Inline: all branches share one line." + ) { + Picker("", selection: $sidebarBranchVerticalLayout) { + Text("Vertical").tag(true) + Text("Inline").tag(false) + } + .labelsHidden() + .pickerStyle(.menu) + } + + } + + SettingsSectionHeader(title: "Workspace Colors") + SettingsCard { + SettingsCardRow( + "Workspace Color Indicator", + controlWidth: pickerColumnWidth + ) { + Picker("", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + + SettingsCardDivider() + + SettingsCardNote("Customize the workspace color palette used by Sidebar > Tab Color. \"Choose Custom Color...\" entries are persisted below.") + + ForEach(Array(workspaceTabDefaultEntries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { + SettingsCardDivider() + } + SettingsCardRow( + entry.name, + subtitle: "Base: \(baseTabColorHex(for: entry.name))" + ) { + HStack(spacing: 8) { + ColorPicker( + "", + selection: defaultTabColorBinding(for: entry.name), + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(entry.hex) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + } + + SettingsCardDivider() + + if workspaceTabCustomColors.isEmpty { + SettingsCardNote("Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu.") + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Custom Colors") + .font(.system(size: 13, weight: .semibold)) + + ForEach(workspaceTabCustomColors, id: \.self) { hex in + HStack(spacing: 8) { + Circle() + .fill(Color(nsColor: NSColor(hex: hex) ?? .gray)) + .frame(width: 11, height: 11) + + Text(hex) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + + Spacer(minLength: 8) + + Button("Remove") { + removeWorkspaceCustomColor(hex) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + SettingsCardDivider() + + SettingsCardRow( + "Reset Palette", + subtitle: "Restore built-in defaults and clear all custom colors." + ) { + Button("Reset") { + resetWorkspaceTabColors() + } + .buttonStyle(.bordered) + .controlSize(.small) + } } SettingsSectionHeader(title: "Automation") @@ -2527,7 +2935,7 @@ struct SettingsView: View { subtitle: selectedSocketControlMode.description, controlWidth: pickerColumnWidth ) { - Picker("", selection: $socketControlMode) { + Picker("", selection: socketModeSelection) { ForEach(SocketControlMode.uiCases) { mode in Text(mode.displayName).tag(mode.rawValue) } @@ -2539,8 +2947,51 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardNote("Controls access to the local Unix socket for programmatic control. In \"cmux processes only\" mode, only processes spawned inside cmux terminals can connect.") - SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH.") + SettingsCardNote("Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.") + if selectedSocketControlMode == .password { + SettingsCardDivider() + SettingsCardRow( + "Socket Password", + subtitle: hasSocketPasswordConfigured + ? "Stored in login keychain." + : "No password set. External clients will be blocked until one is configured." + ) { + HStack(spacing: 8) { + SecureField("Password", text: $socketPasswordDraft) + .textFieldStyle(.roundedBorder) + .frame(width: 170) + Button(hasSocketPasswordConfigured ? "Change" : "Set") { + saveSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if hasSocketPasswordConfigured { + Button("Clear") { + clearSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + if let message = socketPasswordStatusMessage { + Text(message) + .font(.caption) + .foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary) + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + } + if selectedSocketControlMode == .allowAll { + SettingsCardDivider() + Text("Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.") + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).") } SettingsCard { @@ -2607,6 +3058,24 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Browser Theme", + subtitle: selectedBrowserThemeMode == .system + ? "System follows app and macOS appearance." + : "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages.", + controlWidth: pickerColumnWidth + ) { + Picker("", selection: browserThemeModeSelection) { + ForEach(BrowserThemeMode.allCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + + SettingsCardDivider() + SettingsCardRow( "Open Terminal Links in cmux Browser", subtitle: "When off, links clicked in terminal output open in your default browser." @@ -2616,13 +3085,24 @@ struct SettingsView: View { .controlSize(.small) } - if openTerminalLinksInCmuxBrowser { + SettingsCardDivider() + + SettingsCardRow( + "Intercept open http(s) in Terminal", + subtitle: "When off, `open https://...` and `open http://...` always use your default browser." + ) { + Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + + if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { SettingsCardDivider() VStack(alignment: .leading, spacing: 6) { SettingsCardRow( "Hosts to Open in Embedded Browser", - subtitle: "When you click links in terminal output, only these hosts open in cmux. Other hosts open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all links in cmux." + subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux." ) { EmptyView() } @@ -2830,8 +3310,10 @@ struct SettingsView: View { .toggleStyle(.switch) .onAppear { BrowserHistoryStore.shared.loadIfNeeded() + browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + reloadWorkspaceTabColorSettings() } .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in // Keep draft in sync with external changes unless the user has local unsaved edits. @@ -2842,6 +3324,9 @@ struct SettingsView: View { .onReceive(BrowserHistoryStore.shared.$entries) { entries in browserHistoryEntryCount = entries.count } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + reloadWorkspaceTabColorSettings() + } .confirmationDialog( "Clear browser history?", isPresented: $showClearBrowserHistoryConfirmation, @@ -2854,6 +3339,21 @@ struct SettingsView: View { } message: { Text("This removes visited-page suggestions from the browser omnibar.") } + .confirmationDialog( + "Enable full open access?", + isPresented: $showOpenAccessConfirmation, + titleVisibility: .visible + ) { + Button("Enable Full Open Access", role: .destructive) { + socketControlMode = (pendingOpenAccessMode ?? .allowAll).rawValue + pendingOpenAccessMode = nil + } + Button("Cancel", role: .cancel) { + pendingOpenAccessMode = nil + } + } message: { + Text("This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.") + } } private func resetAllSettings() { @@ -2862,17 +3362,65 @@ struct SettingsView: View { claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled + browserThemeMode = BrowserThemeSettings.defaultMode.rawValue openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled + warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue + sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + showOpenAccessConfirmation = false + pendingOpenAccessMode = nil + socketPasswordDraft = "" + socketPasswordStatusMessage = nil + socketPasswordStatusIsError = false KeyboardShortcutSettings.resetAll() + WorkspaceTabColorSettings.reset() + reloadWorkspaceTabColorSettings() shortcutResetToken = UUID() } + private func defaultTabColorBinding(for name: String) -> Binding { + Binding( + get: { + let hex = WorkspaceTabColorSettings.defaultColorHex(named: name) + return Color(nsColor: NSColor(hex: hex) ?? .systemBlue) + }, + set: { newValue in + let hex = NSColor(newValue).hexString() + WorkspaceTabColorSettings.setDefaultColor(named: name, hex: hex) + reloadWorkspaceTabColorSettings() + } + ) + } + + private func baseTabColorHex(for name: String) -> String { + WorkspaceTabColorSettings.defaultPalette + .first(where: { $0.name == name })? + .hex ?? "#1565C0" + } + + private func removeWorkspaceCustomColor(_ hex: String) { + WorkspaceTabColorSettings.removeCustomColor(hex) + reloadWorkspaceTabColorSettings() + } + + private func resetWorkspaceTabColors() { + WorkspaceTabColorSettings.reset() + reloadWorkspaceTabColorSettings() + } + + private func reloadWorkspaceTabColorSettings() { + workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() + workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() + } + private func saveBrowserInsecureHTTPAllowlist() { browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift new file mode 100644 index 00000000..59796d6d --- /dev/null +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -0,0 +1,450 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class AppDelegateShortcutRoutingTests: XCTestCase { + func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + XCTAssertTrue(appDelegate.focusMainWindow(windowId: firstWindowId)) + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: secondWindow.windowNumber, + context: nil, + characters: "n", + charactersIgnoringModifiers: "n", + isARepeat: false, + keyCode: 45 + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not add workspace to stale active window") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should add workspace to the event's window") + } + + func testAddWorkspaceInPreferredMainWindowIgnoresStaleTabManagerPointer() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + // Force a stale app-level pointer to a different manager. + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + _ = appDelegate.addWorkspaceInPreferredMainWindow() + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Stale pointer must not receive menu-driven workspace creation") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Workspace creation should target key/main window context") + } + + func testCmdNResolvesEventWindowWhenObjectKeyLookupIsMismatched() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId)) +#else + XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG") +#endif + + // Ensure stale active-manager pointer does not mask routing errors. + appDelegate.tabManager = firstManager + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: secondWindow.windowNumber, + context: nil, + characters: "n", + charactersIgnoringModifiers: "n", + isARepeat: false, + keyCode: 45 + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not route to another window when object-key lookup misses") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should still route by event window metadata when object-key lookup misses") + } + + func testAddWorkspaceInPreferredMainWindowUsesKeyWindowWhenObjectKeyLookupIsMismatched() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId)) +#else + XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG") +#endif + + // Stale pointer should not receive the new workspace. + appDelegate.tabManager = firstManager + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + _ = appDelegate.addWorkspaceInPreferredMainWindow() + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Menu-driven add workspace should not route to stale window") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses") + } + + func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + _ = firstManager.addTab(select: true) + _ = secondManager.addTab(select: true) + + guard let firstSelectedBefore = firstManager.selectedTabId, + let secondSelectedBefore = secondManager.selectedTabId else { + XCTFail("Expected selected tabs in both windows") + return + } + guard let secondFirstTabId = secondManager.tabs.first?.id else { + XCTFail("Expected at least one tab in second window") + return + } + + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + guard let event = makeKeyDownEvent( + key: "1", + modifiers: [.command], + keyCode: 18, // kVK_ANSI_1 + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+1 event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Cmd+1 must not select a tab in stale active window") + XCTAssertNotEqual(secondManager.selectedTabId, secondSelectedBefore, "Cmd+1 should change tab selection in event window") + XCTAssertEqual(secondManager.selectedTabId, secondFirstTabId, "Cmd+1 should select first tab in the event window") + XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") + } + + func testCmdTRoutesToEventWindowWhenActiveManagerIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId), + let firstWorkspace = firstManager.selectedWorkspace, + let secondWorkspace = secondManager.selectedWorkspace else { + XCTFail("Expected both window contexts to exist") + return + } + + let firstSurfaceCount = firstWorkspace.panels.count + let secondSurfaceCount = secondWorkspace.panels.count + + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + guard let event = makeKeyDownEvent( + key: "t", + modifiers: [.command], + keyCode: 17, // kVK_ANSI_T + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+T event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+T must not create a surface in stale active window") + XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+T should create a surface in the event window") + XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") + } + + func testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + _ = firstManager.addTab(select: true) + _ = secondManager.addTab(select: true) + guard let firstSelectedBefore = firstManager.selectedTabId, + let secondSelectedBefore = secondManager.selectedTabId else { + XCTFail("Expected selected tabs in both windows") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + // Force stale app-level manager to first window while keyboard event + // references no known window. + appDelegate.tabManager = firstManager + + guard let event = makeKeyDownEvent( + key: "1", + modifiers: [.command], + keyCode: 18, + windowNumber: Int.max + ) else { + XCTFail("Failed to construct Cmd+1 event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Unresolved event window must not route Cmd+1 into stale manager") + XCTAssertEqual(secondManager.selectedTabId, secondSelectedBefore, "Unresolved event window must not route Cmd+1 into key/main fallback manager") + XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") + } + + func testCmdNDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + appDelegate.tabManager = firstManager + + guard let event = makeKeyDownEvent( + key: "n", + modifiers: [.command], + keyCode: 45, + windowNumber: Int.max + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Unresolved event window must not create workspace in stale manager") + XCTAssertEqual(secondManager.tabs.count, secondCount, "Unresolved event window must not create workspace in fallback window") + XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") + } + + private func makeKeyDownEvent( + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int + ) -> NSEvent? { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) + } + + private func window(withId windowId: UUID) -> NSWindow? { + let identifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + } + + private func closeWindow(withId windowId: UUID) { + guard let window = window(withId: windowId) else { return } + window.performClose(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } +} diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 4b391978..4191c7fc 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -642,6 +642,73 @@ final class CJKIMECompositionSequenceTests: XCTestCase { } } +// MARK: - IME firstRect placement and sizing + +/// Regression tests for IME candidate/preedit anchor rectangle reporting. +/// If width/height are discarded here, macOS can place preedit UI incorrectly. +final class CJKIMEFirstRectTests: XCTestCase { + + func testFirstRectUsesIMEProvidedWidthAndHeight() { + let frame = NSRect(x: 0, y: 0, width: 800, height: 600) + let view = GhosttyNSView(frame: frame) + view.cellSize = CGSize(width: 10, height: 20) + view.setIMEPointForTesting(x: 120, y: 240, width: 64, height: 26) + + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: frame) + window.contentView = content + content.addSubview(view) + view.frame = frame + + defer { + view.clearIMEPointForTesting() + window.orderOut(nil) + } + + let rect = view.firstRect(forCharacterRange: NSRange(location: 0, length: 1), actualRange: nil) + + let expectedViewRect = NSRect(x: 120, y: frame.height - 240, width: 64, height: 26) + let expectedScreenRect = window.convertToScreen(view.convert(expectedViewRect, to: nil)) + + XCTAssertEqual(rect.origin.x, expectedScreenRect.origin.x, accuracy: 0.001) + XCTAssertEqual(rect.origin.y, expectedScreenRect.origin.y, accuracy: 0.001) + XCTAssertEqual(rect.width, 64, accuracy: 0.001) + XCTAssertEqual(rect.height, 26, accuracy: 0.001) + } + + func testFirstRectFallsBackToCellHeightWhenIMEHeightIsZero() { + let frame = NSRect(x: 0, y: 0, width: 640, height: 480) + let view = GhosttyNSView(frame: frame) + view.cellSize = CGSize(width: 9, height: 18) + view.setIMEPointForTesting(x: 80, y: 120, width: 36, height: 0) + + let window = NSWindow( + contentRect: NSRect(x: 40, y: 40, width: 640, height: 480), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: frame) + window.contentView = content + content.addSubview(view) + view.frame = frame + + defer { + view.clearIMEPointForTesting() + window.orderOut(nil) + } + + let rect = view.firstRect(forCharacterRange: NSRange(location: 0, length: 1), actualRange: nil) + XCTAssertEqual(rect.width, 36, accuracy: 0.001) + XCTAssertEqual(rect.height, 18, accuracy: 0.001) + } +} + // MARK: - Key text accumulator during CJK IME composition /// Tests that the keyTextAccumulator correctly manages text during the keyDown diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e27d3db3..9a0e10b6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1,6 +1,8 @@ import XCTest import AppKit +import SwiftUI import WebKit +import SwiftUI import ObjectiveC.runtime #if canImport(cmux_DEV) @@ -52,6 +54,52 @@ private func installCmuxUnitTestInspectorOverride() { cmuxUnitTestInspectorOverrideInstalled = true } +final class SplitShortcutTransientFocusGuardTests: XCTestCase { + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: false + ) + ) + } + + func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: false, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } +} + final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class ActionSpy: NSObject { private(set) var invoked: Bool = false @@ -61,6 +109,10 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + private final class FirstResponderView: NSView { + override var acceptsFirstResponder: Bool { true } + } + func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { let spy = ActionSpy() installMenu(spy: spy, key: "n", modifiers: [.command]) @@ -97,6 +149,234 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertTrue(spy.invoked) } + func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: []) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: [.command]) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: []) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + @MainActor + func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(webView)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(webView.becomeFirstResponder()) + + _ = window.makeFirstResponder(webView) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView)) + } + } + + @MainActor + func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy") + + webView.withPointerFocusAllowance { + XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy") + } + + _ = window.makeFirstResponder(nil) + XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary") + } + + @MainActor + func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(descendant)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(window.makeFirstResponder(descendant)) + + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView)) + } + } + + @MainActor + func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance") + + _ = window.makeFirstResponder(nil) + webView.withPointerFocusAllowance { + XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard") + } + + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary") + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context") + + let timestamp = ProcessInfo.processInfo.systemUptime + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 5, y: 5), + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) + _ = window.makeFirstResponder(nil) + XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy") + + AppDelegate.clearWindowFirstResponderGuardTesting() + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context") + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu() @@ -132,6 +412,237 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +@MainActor +final class AppDelegateWindowContextRoutingTests: XCTestCase { + private func makeMainWindow(id: UUID) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") + return window + } + + func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + windowB.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB) + XCTAssertTrue(app.tabManager === managerB) + + windowA.makeKeyAndOrderFront(nil) + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // Seed active manager and clear focus windows to force fallback routing. + windowA.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(app.tabManager === managerA) + windowA.orderOut(nil) + windowB.orderOut(nil) + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil) + XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.orderOut(nil) } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // SwiftUI can replace the NSWindow identifier string at runtime. + window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged") + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window) + XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed") + XCTAssertTrue(app.tabManager === manager) + } +} + +final class FocusFlashPatternTests: XCTestCase { + func testFocusFlashPatternMatchesTerminalDoublePulseShape() { + XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0]) + XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1]) + XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn]) + XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001) + } + + func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() { + let segments = FocusFlashPattern.segments + XCTAssertEqual(segments.count, 4) + + XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001) + XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001) + XCTAssertEqual(segments[0].curve, .easeOut) + + XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001) + XCTAssertEqual(segments[1].curve, .easeIn) + + XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001) + XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001) + XCTAssertEqual(segments[2].curve, .easeOut) + + XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001) + XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001) + XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001) + XCTAssertEqual(segments[3].curve, .easeIn) + } +} + +@MainActor +final class CmuxWebViewContextMenuTests: XCTestCase { + private func makeRightMouseDownEvent() -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: .rightMouseDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create rightMouseDown event") + } + return event + } + + func testWillOpenMenuAddsOpenLinkInDefaultBrowserAndRoutesSelectionToDefaultBrowserOpener() { + _ = NSApplication.shared + let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let openLinkItem = NSMenuItem(title: "Open Link", action: nil, keyEquivalent: "") + openLinkItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierOpenLink") + menu.addItem(openLinkItem) + menu.addItem(NSMenuItem(title: "Copy Link", action: nil, keyEquivalent: "")) + + var openedURL: URL? + webView.contextMenuLinkURLProvider = { _, _, completion in + completion(URL(string: "https://example.com/docs")!) + } + webView.contextMenuDefaultBrowserOpener = { url in + openedURL = url + return true + } + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + guard let defaultBrowserItemIndex = menu.items.firstIndex(where: { $0.title == "Open Link in Default Browser" }) else { + XCTFail("Expected Open Link in Default Browser item in context menu") + return + } + guard let openLinkIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" }) else { + XCTFail("Expected Open Link item in context menu") + return + } + + XCTAssertEqual(defaultBrowserItemIndex, openLinkIndex + 1) + let defaultBrowserItem = menu.items[defaultBrowserItemIndex] + XCTAssertTrue(defaultBrowserItem.target === webView) + XCTAssertNotNil(defaultBrowserItem.action) + + let dispatched = NSApp.sendAction( + defaultBrowserItem.action!, + to: defaultBrowserItem.target, + from: defaultBrowserItem + ) + XCTAssertTrue(dispatched) + XCTAssertEqual(openedURL?.absoluteString, "https://example.com/docs") + } + + func testWillOpenMenuSkipsDefaultBrowserItemWhenContextHasNoOpenLinkEntry() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Back", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Forward", action: nil, keyEquivalent: "")) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" }) + } +} + final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { private func makeIsolatedDefaults() -> UserDefaults { let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)" @@ -183,6 +694,192 @@ final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { } } +final class BrowserThemeSettingsTests: XCTestCase { + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "BrowserThemeSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create defaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + } + return defaults + } + + func testDefaultsMatchConfiguredFallbacks() { + let defaults = makeIsolatedDefaults() + XCTAssertEqual( + BrowserThemeSettings.mode(defaults: defaults), + BrowserThemeSettings.defaultMode + ) + } + + func testModeReadsPersistedValue() { + let defaults = makeIsolatedDefaults() + defaults.set(BrowserThemeMode.dark.rawValue, forKey: BrowserThemeSettings.modeKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark) + + defaults.set(BrowserThemeMode.light.rawValue, forKey: BrowserThemeSettings.modeKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .light) + } + + func testModeMigratesLegacyForcedDarkModeFlag() { + let defaults = makeIsolatedDefaults() + defaults.set(true, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark) + XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.dark.rawValue) + + let otherDefaults = makeIsolatedDefaults() + otherDefaults.set(false, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey) + XCTAssertEqual(BrowserThemeSettings.mode(defaults: otherDefaults), .system) + XCTAssertEqual(otherDefaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.system.rawValue) + } +} + +final class BrowserPanelChromeBackgroundColorTests: XCTestCase { + func testLightModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .light) + } + + func testDarkModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .dark) + } + + private func assertResolvedColorMatchesTheme( + for colorScheme: ColorScheme, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0) + + guard + let actual = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expected = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line) + } +} + +final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { + func testLightModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) + } + + func testDarkModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) + } + + private func assertResolvedColorMatchesExpectedBlend( + for colorScheme: ColorScheme, + darkenMix: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground + + guard + let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expectedSRGB = expected.usingColorSpace(.sRGB), + let themeSRGB = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) + XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) + } +} + +final class SidebarActiveForegroundColorTests: XCTestCase { + func testLightAppearanceUsesBlackWithRequestedOpacity() { + guard let lightAppearance = NSAppearance(named: .aqua), + let color = sidebarActiveForegroundNSColor( + opacity: 0.8, + appAppearance: lightAppearance + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) + } + + func testDarkAppearanceUsesWhiteWithRequestedOpacity() { + guard let darkAppearance = NSAppearance(named: .darkAqua), + let color = sidebarActiveForegroundNSColor( + opacity: 0.65, + appAppearance: darkAppearance + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } +} + +final class SidebarSelectedWorkspaceColorTests: XCTestCase { + func testLightModeUsesConfiguredSelectedWorkspaceBackgroundColor() { + guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .light).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func testDarkModeUsesConfiguredSelectedWorkspaceBackgroundColor() { + guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() { + guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } +} final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { func testSafariDefaultShortcutForToggleDeveloperTools() { let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut @@ -203,6 +900,125 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { } } +final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { + func testRenameTabShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab") + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab") + + let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut + XCTAssertEqual(shortcut.key, "r") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWindowShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow") + + let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertTrue(shortcut.control) + } + + func testRenameWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace") + + let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + XCTAssertEqual(shortcut.key, "r") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testRenameWorkspaceShortcutConvertsToMenuShortcut() { + let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + XCTAssertNotNil(shortcut.keyEquivalent) + XCTAssertTrue(shortcut.eventModifiers.contains(.command)) + XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) + XCTAssertFalse(shortcut.eventModifiers.contains(.option)) + XCTAssertFalse(shortcut.eventModifiers.contains(.control)) + } + + func testCloseWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace") + + let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWorkspaceShortcutConvertsToMenuShortcut() { + let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + XCTAssertNotNil(shortcut.keyEquivalent) + XCTAssertTrue(shortcut.eventModifiers.contains(.command)) + XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) + XCTAssertFalse(shortcut.eventModifiers.contains(.option)) + XCTAssertFalse(shortcut.eventModifiers.contains(.control)) + } + + func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab") + XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab") + + let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + XCTAssertEqual(nextShortcut.key, "]") + XCTAssertTrue(nextShortcut.command) + XCTAssertFalse(nextShortcut.shift) + XCTAssertFalse(nextShortcut.option) + XCTAssertTrue(nextShortcut.control) + + let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + XCTAssertEqual(prevShortcut.key, "[") + XCTAssertTrue(prevShortcut.command) + XCTAssertFalse(prevShortcut.shift) + XCTAssertFalse(prevShortcut.option) + XCTAssertTrue(prevShortcut.control) + } + + func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() { + let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + XCTAssertNotNil(nextShortcut.keyEquivalent) + XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]") + XCTAssertTrue(nextShortcut.eventModifiers.contains(.command)) + XCTAssertTrue(nextShortcut.eventModifiers.contains(.control)) + + let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + XCTAssertNotNil(prevShortcut.keyEquivalent) + XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[") + XCTAssertTrue(prevShortcut.eventModifiers.contains(.command)) + XCTAssertTrue(prevShortcut.eventModifiers.contains(.control)) + } + + func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() { + XCTAssertNotNil(StoredShortcut(key: "←", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "→", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "↑", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertEqual( + StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent, + "\t" + ) + } + + func testShortcutDefaultsKeysRemainUnique() { + let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey) + XCTAssertEqual(Set(keys).count, keys.count) + } +} + @MainActor final class BrowserDeveloperToolsConfigurationTests: XCTestCase { func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { @@ -214,6 +1030,279 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { XCTAssertTrue(panel.webView.isInspectable) } } + + func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() { + let panel = BrowserPanel(workspaceId: UUID()) + let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) + let updatedOpacity = 0.57 + + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: updatedColor, + GhosttyNotificationKey.backgroundOpacity: updatedOpacity + ] + ) + + guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB), + let expected = updatedColor.withAlphaComponent(updatedOpacity).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible under-page background colors") + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005) + } + + func testBrowserPanelStartsAsNewTabWithoutLoadingAboutBlank() { + let panel = BrowserPanel(workspaceId: UUID()) + + XCTAssertEqual(panel.displayTitle, "New tab") + XCTAssertFalse(panel.shouldRenderWebView) + XCTAssertTrue(panel.isShowingNewTabPage) + XCTAssertNil(panel.webView.url) + XCTAssertNil(panel.currentURL) + } + + func testBrowserPanelLeavesNewTabPageStateWhenNavigationStarts() { + let panel = BrowserPanel(workspaceId: UUID()) + + XCTAssertTrue(panel.isShowingNewTabPage) + panel.navigate(to: URL(string: "https://example.com")!) + XCTAssertFalse(panel.isShowingNewTabPage) + } + + func testBrowserPanelThemeModeUpdatesWebViewAppearance() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.setBrowserThemeMode(.dark) + XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.darkAqua, .aqua]), .darkAqua) + + panel.setBrowserThemeMode(.light) + XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.aqua, .darkAqua]), .aqua) + + panel.setBrowserThemeMode(.system) + XCTAssertNil(panel.webView.appearance) + } +} + +final class BrowserNavigationNewTabDecisionTests: XCTestCase { + func testLinkActivatedCmdClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } + + func testLinkActivatedMiddleClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedPlainLeftClickStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testOtherNavigationLeftClickStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 4, + hasRecentMiddleClickIntent: false + ) + ) + } + + func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 4, + hasRecentMiddleClickIntent: true + ) + ) + } + + func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0, + currentEventType: .otherMouseUp, + currentEventButtonNumber: 2 + ) + ) + } + + func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .reload, + modifierFlags: [], + buttonNumber: 0, + currentEventType: .otherMouseUp, + currentEventButtonNumber: 2 + ) + ) + } + + func testNonLinkNavigationNeverForcesNewTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .reload, + modifierFlags: [.command], + buttonNumber: 2 + ) + ) + } +} + +@MainActor +final class BrowserJavaScriptDialogDelegateTests: XCTestCase { + func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() { + let panel = BrowserPanel(workspaceId: UUID()) + guard let uiDelegate = panel.webView.uiDelegate as? NSObject else { + XCTFail("Expected BrowserPanel webView.uiDelegate to be an NSObject") + return + } + + XCTAssertTrue( + uiDelegate.responds( + to: #selector( + WKUIDelegate.webView( + _:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler: + ) + ) + ), + "Browser UI delegate must implement JavaScript alert handling" + ) + XCTAssertTrue( + uiDelegate.responds( + to: #selector( + WKUIDelegate.webView( + _:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler: + ) + ) + ), + "Browser UI delegate must implement JavaScript confirm handling" + ) + XCTAssertTrue( + uiDelegate.responds( + to: #selector( + WKUIDelegate.webView( + _:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler: + ) + ) + ), + "Browser UI delegate must implement JavaScript prompt handling" + ) + } +} + +@MainActor +final class BrowserSessionHistoryRestoreTests: XCTestCase { + func testSessionNavigationHistorySnapshotUsesRestoredStacks() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.restoreSessionNavigationHistory( + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ], + currentURLString: "https://example.com/c" + ) + + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + + let snapshot = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual( + snapshot.backHistoryURLStrings, + ["https://example.com/a", "https://example.com/b"] + ) + XCTAssertEqual( + snapshot.forwardHistoryURLStrings, + ["https://example.com/d"] + ) + } + + func testSessionNavigationHistoryBackAndForwardUpdateStacks() { + let panel = BrowserPanel(workspaceId: UUID()) + + panel.restoreSessionNavigationHistory( + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ], + currentURLString: "https://example.com/c" + ) + + panel.goBack() + let afterBack = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual(afterBack.backHistoryURLStrings, ["https://example.com/a"]) + XCTAssertEqual( + afterBack.forwardHistoryURLStrings, + ["https://example.com/c", "https://example.com/d"] + ) + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + + panel.goForward() + let afterForward = panel.sessionNavigationHistorySnapshot() + XCTAssertEqual( + afterForward.backHistoryURLStrings, + ["https://example.com/a", "https://example.com/b"] + ) + XCTAssertEqual(afterForward.forwardHistoryURLStrings, ["https://example.com/d"]) + XCTAssertTrue(panel.canGoBack) + XCTAssertTrue(panel.canGoForward) + } } @MainActor @@ -433,6 +1522,25 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { ) } + func testArrowNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 126 + ), + -1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 125 + ), + 1 + ) + } + func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() { XCTAssertNil( browserOmnibarSelectionDeltaForCommandNavigation( @@ -486,6 +1594,491 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { 1 ) } + + func testCommandNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.control, .capsLock], + chars: "n" + ), + 1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .capsLock], + chars: "p" + ), + -1 + ) + } + + func testSubmitOnReturnIgnoresCapsLockModifier() { + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock])) + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock])) + } +} + +final class BrowserReturnKeyDownRoutingTests: XCTestCase { + func testRoutesForReturnWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true + ) + ) + } + + func testRoutesForKeypadEnterWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 76, + firstResponderIsBrowser: true + ) + ) + } + + func testDoesNotRouteForNonEnterKey() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 13, + firstResponderIsBrowser: true + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotBrowser() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: false + ) + ) + } +} + +final class BrowserZoomShortcutActionTests: XCTestCase { + func testZoomInSupportsEqualsAndPlusVariants() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "=", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24), + .zoomIn + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30), + .zoomIn + ) + } + + func testZoomOutSupportsMinusAndUnderscoreVariants() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "-", keyCode: 27), + .zoomOut + ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command, .shift], chars: "_", keyCode: 27), + .zoomOut + ) + } + + func testZoomRequiresCommandWithoutOptionOrControl() { + XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24)) + XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24)) + XCTAssertNil(browserZoomShortcutAction(flags: [.command, .control], chars: "-", keyCode: 27)) + } + + func testResetSupportsCommandZero() { + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "0", keyCode: 29), + .reset + ) + } +} + +final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { + func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() { + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "=", + keyCode: 24 + ) + ) + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "-", + keyCode: 27 + ) + ) + XCTAssertTrue( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "0", + keyCode: 29 + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotGhostty() { + XCTAssertFalse( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: false, + flags: [.command], + chars: "=", + keyCode: 24 + ) + ) + } + + func testDoesNotRouteForNonZoomShortcuts() { + XCTAssertFalse( + shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + } +} + +final class GhosttyResponderResolutionTests: XCTestCase { + private final class FocusProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + func testResolvesGhosttyViewFromDescendantResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + ghosttyView.addSubview(descendant) + + XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView) + } + + func testResolvesGhosttyViewFromGhosttyResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView) + } + + func testReturnsNilForUnrelatedResponder() { + let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + XCTAssertNil(cmuxOwningGhosttyView(for: view)) + } +} + +final class CommandPaletteKeyboardNavigationTests: XCTestCase { + func testArrowKeysMoveSelectionWithoutModifiers() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 125 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 126 + ), + -1 + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.shift], + chars: "", + keyCode: 125 + ) + ) + } + + func testControlLetterNavigationSupportsPrintableAndControlChars() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "n", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0e}", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "p", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{10}", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "j", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0a}", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "k", + keyCode: 40 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0b}", + keyCode: 40 + ), + -1 + ) + } + + func testIgnoresUnsupportedModifiersAndKeys() { + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control, .shift], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "x", + keyCode: 7 + ) + ) + } +} + +final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { + func testDoesNotConsumeWhenPaletteIsNotVisible() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: false, + normalizedFlags: [.command], + chars: "n", + keyCode: 45 + ) + ) + } + + func testConsumesAppCommandShortcutsWhenPaletteIsVisible() { + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "t", + keyCode: 17 + ) + ) + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command, .shift], + chars: ",", + keyCode: 43 + ) + ) + } + + func testAllowsClipboardAndUndoShortcutsForPaletteTextEditing() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "v", + keyCode: 9 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "z", + keyCode: 6 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command, .shift], + chars: "z", + keyCode: 6 + ) + ) + } + + func testAllowsArrowAndDeleteEditingCommandsForPaletteTextEditing() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "", + keyCode: 123 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "", + keyCode: 51 + ) + ) + } +} + +final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase { + func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() { + let panelId = UUID() + XCTAssertTrue( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: true, + focusedBrowserAddressBarPanelId: panelId, + focusedPanelId: panelId + ) + ) + } + + func testDoesNotRestoreBrowserAddressBarWhenFocusedPanelIsNotBrowser() { + let panelId = UUID() + XCTAssertFalse( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: false, + focusedBrowserAddressBarPanelId: panelId, + focusedPanelId: panelId + ) + ) + } + + func testDoesNotRestoreBrowserAddressBarWhenAnotherPanelHadAddressBarFocus() { + XCTAssertFalse( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: true, + focusedBrowserAddressBarPanelId: UUID(), + focusedPanelId: UUID() + ) + ) + } +} + +final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { + private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)" + + private func makeDefaults() -> UserDefaults { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + func testDefaultsToSelectAllWhenUnset() { + let defaults = makeDefaults() + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsFalseWhenStoredFalse() { + let defaults = makeDefaults() + defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsTrueWhenStoredTrue() { + let defaults = makeDefaults() + defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } +} + +final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { + func testFirstEntryPinsToTopAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 0, + resultCount: 20 + ) + XCTAssertEqual(anchor, UnitPoint.top) + } + + func testLastEntryPinsToBottomAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 19, + resultCount: 20 + ) + XCTAssertEqual(anchor, UnitPoint.bottom) + } + + func testMiddleEntryUsesNilAnchorForMinimalScroll() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 6, + resultCount: 20 + ) + XCTAssertNil(anchor) + } + + func testEmptyResultsProduceNoAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( + selectedIndex: 0, + resultCount: 0 + ) + XCTAssertNil(anchor) + } } final class SidebarCommandHintPolicyTests: XCTestCase { @@ -686,6 +2279,224 @@ final class WorkspacePlacementSettingsTests: XCTestCase { } } +@MainActor +final class WorkspaceCreationPlacementTests: XCTestCase { + func testAddWorkspaceDefaultPlacementMatchesCurrentSetting() { + let currentPlacement = WorkspacePlacementSettings.current() + + let defaultManager = makeManagerWithThreeWorkspaces() + let defaultBaselineOrder = defaultManager.tabs.map(\.id) + let defaultInserted = defaultManager.addWorkspace() + guard let defaultInsertedIndex = defaultManager.tabs.firstIndex(where: { $0.id == defaultInserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + XCTAssertEqual(defaultManager.tabs.map(\.id).filter { $0 != defaultInserted.id }, defaultBaselineOrder) + + let explicitManager = makeManagerWithThreeWorkspaces() + let explicitBaselineOrder = explicitManager.tabs.map(\.id) + let explicitInserted = explicitManager.addWorkspace(placementOverride: currentPlacement) + guard let explicitInsertedIndex = explicitManager.tabs.firstIndex(where: { $0.id == explicitInserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + XCTAssertEqual(explicitManager.tabs.map(\.id).filter { $0 != explicitInserted.id }, explicitBaselineOrder) + XCTAssertEqual(defaultInsertedIndex, explicitInsertedIndex) + } + + func testAddWorkspaceEndOverrideAlwaysAppends() { + let manager = makeManagerWithThreeWorkspaces() + let baselineCount = manager.tabs.count + guard baselineCount >= 3 else { + XCTFail("Expected at least three workspaces for placement regression test") + return + } + + let inserted = manager.addWorkspace(placementOverride: .end) + guard let insertedIndex = manager.tabs.firstIndex(where: { $0.id == inserted.id }) else { + XCTFail("Expected inserted workspace in tab list") + return + } + + XCTAssertEqual(insertedIndex, baselineCount) + } + + private func makeManagerWithThreeWorkspaces() -> TabManager { + let manager = TabManager() + _ = manager.addWorkspace() + _ = manager.addWorkspace() + if let first = manager.tabs.first { + manager.selectWorkspace(first) + } + return manager + } +} + +final class WorkspaceTabColorSettingsTests: XCTestCase { + func testNormalizedHexAcceptsAndNormalizesValidInput() { + XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123") + XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF") + XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234")) + XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234")) + } + + func testBuiltInPaletteMatchesOriginalPRPalette() { + let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults) + XCTAssertEqual(palette.count, 16) + XCTAssertEqual(palette.first?.name, "Red") + XCTAssertEqual(palette.first?.hex, "#C0392B") + XCTAssertEqual(palette.last?.name, "Charcoal") + XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) + } + + func testDefaultOverrideRoundTripFallsBackWhenResetToBase() { + let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = WorkspaceTabColorSettings.defaultPalette[0] + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + "#00AA33" + ) + + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + } + + func testAddCustomColorPersistsAndDeduplicatesByMostRecent() { + let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults), + "#00AA33" + ) + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults), + "#112233" + ) + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults), + "#00AA33" + ) + XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults)) + + XCTAssertEqual( + WorkspaceTabColorSettings.customColors(defaults: defaults), + ["#00AA33", "#112233"] + ) + } + + func testPaletteIncludesCustomEntriesAndResetClearsAll() { + let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = WorkspaceTabColorSettings.defaultPalette[0] + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults) + _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults) + + let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1) + XCTAssertEqual(paletteBeforeReset[0].hex, "#334455") + XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1") + XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899") + + WorkspaceTabColorSettings.reset(defaults: defaults) + + XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), []) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + } + + func testDisplayColorLightModeKeepsOriginalHex() { + let originalHex = "#1A5276" + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .light + ) + + XCTAssertEqual(rendered?.hexString(), originalHex) + } + + func testDisplayColorDarkModeBrightensColor() { + let originalHex = "#1A5276" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .dark + ) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertNotEqual(rendered.hexString(), originalHex) + XCTAssertGreaterThan(rendered.luminance, base.luminance) + } + + func testDisplayColorDarkModeKeepsGrayscaleNeutral() { + let originalHex = "#808080" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .dark + ), + let renderedSRGB = rendered.usingColorSpace(.sRGB) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertGreaterThan(rendered.luminance, base.luminance) + XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003) + XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003) + } + + func testDisplayColorForceBrightensInLightMode() { + let originalHex = "#1A5276" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .light, + forceBright: true + ) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertNotEqual(rendered.hexString(), originalHex) + XCTAssertGreaterThan(rendered.luminance, base.luminance) + } +} + final class WorkspaceAutoReorderSettingsTests: XCTestCase { func testDefaultIsEnabled() { let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)" @@ -723,6 +2534,72 @@ final class WorkspaceAutoReorderSettingsTests: XCTestCase { } } +final class SidebarBranchLayoutSettingsTests: XCTestCase { + func testDefaultUsesVerticalLayout() { + let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + } + + func testStoredPreferenceOverridesDefault() { + let suiteName = "SidebarBranchLayoutSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: SidebarBranchLayoutSettings.key) + XCTAssertFalse(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + + defaults.set(true, forKey: SidebarBranchLayoutSettings.key) + XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults)) + } +} + +final class SidebarActiveTabIndicatorSettingsTests: XCTestCase { + func testDefaultStyleWhenUnset() { + let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual( + SidebarActiveTabIndicatorSettings.current(defaults: defaults), + SidebarActiveTabIndicatorSettings.defaultStyle + ) + } + + func testStoredStyleParsesAndInvalidFallsBack() { + let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) + + defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) + + defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual( + SidebarActiveTabIndicatorSettings.current(defaults: defaults), + SidebarActiveTabIndicatorSettings.defaultStyle + ) + } +} + final class AppearanceSettingsTests: XCTestCase { func testResolvedModeDefaultsToSystemWhenUnset() { let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" @@ -740,54 +2617,64 @@ final class AppearanceSettingsTests: XCTestCase { } } -final class UpdateChannelSettingsTests: XCTestCase { - func testDefaultNightlyPreferenceIsDisabled() { - XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) - } - - func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() { - let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)" +final class QuitWarningSettingsTests: XCTestCase { + func testDefaultWarnBeforeQuitIsEnabledWhenUnset() { + let suiteName = "QuitWarningSettingsTests.Default.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") return } defer { defaults.removePersistentDomain(forName: suiteName) } - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults) - XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL) + defaults.removeObject(forKey: QuitWarningSettings.warnBeforeQuitKey) + + XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults)) + } + + func testStoredPreferenceOverridesDefault() { + let suiteName = "QuitWarningSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: QuitWarningSettings.warnBeforeQuitKey) + XCTAssertFalse(QuitWarningSettings.isEnabled(defaults: defaults)) + + defaults.set(true, forKey: QuitWarningSettings.warnBeforeQuitKey) + XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults)) + } +} + +final class UpdateChannelSettingsTests: XCTestCase { + func testResolvedFeedFallsBackWhenInfoFeedMissing() { + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil) + XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) + XCTAssertFalse(resolved.isNightly) + XCTAssertTrue(resolved.usedFallback) + } + + func testResolvedFeedFallsBackWhenInfoFeedEmpty() { + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: "") + XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) XCTAssertFalse(resolved.isNightly) XCTAssertTrue(resolved.usedFallback) } func testResolvedFeedUsesInfoFeedForStableChannel() { - let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - let infoFeed = "https://example.com/custom/appcast.xml" - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults) + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) XCTAssertEqual(resolved.url, infoFeed) XCTAssertFalse(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } - func testResolvedFeedUsesNightlyWhenPreferenceEnabled() { - let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey) - let resolved = UpdateChannelSettings.resolvedFeedURLString( - infoFeedURL: "https://example.com/custom/appcast.xml", - defaults: defaults + func testResolvedFeedDetectsNightlyFromInfoFeedURL() { + let resolved = UpdateFeedResolver.resolvedFeedURLString( + infoFeedURL: "https://example.com/nightly/appcast.xml" ) - XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL) + XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml") XCTAssertTrue(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } @@ -827,6 +2714,78 @@ final class WorkspaceReorderTests: XCTestCase { } } +@MainActor +final class TabManagerChildExitCloseTests: XCTestCase { + func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + let third = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + guard let secondPanelId = second.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) + + XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id]) + XCTAssertEqual( + manager.selectedTabId, + third.id, + "Expected selection to stay at the same index after deleting the selected workspace" + ) + } + + func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + guard let secondPanelId = second.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) + + XCTAssertEqual(manager.tabs.map(\.id), [first.id]) + XCTAssertEqual( + manager.selectedTabId, + first.id, + "Expected previous workspace to be selected after closing the last-index workspace" + ) + } + + func testChildExitOnNonLastPanelClosesOnlyPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panel to be created") + return + } + + let panelCountBefore = workspace.panels.count + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.tabs.first?.id, workspace.id) + XCTAssertEqual(workspace.panels.count, panelCountBefore - 1) + XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain") + } +} + @MainActor final class TabManagerPendingUnfocusPolicyTests: XCTestCase { func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { @@ -911,6 +2870,784 @@ final class TabManagerSurfaceCreationTests: XCTestCase { } } +@MainActor +final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { + func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { + XCTFail("Expected workspace split setup to succeed") + return + } + + // Programmatic split focuses the new right panel by default. + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftPanelId, + "Expected inheritance to use the selected terminal in the target pane" + ) + } + + func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected workspace browser setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId) + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected inheritance to fall back to a terminal in the pane when browser is selected" + ) + } + + func testPreferredTerminalPanelWinsWhenProvided() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a terminal panel") + return + } + + let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId) + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected inheritance to prefer last focused terminal when browser is focused in another pane" + ) + } +} + +@MainActor +final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { + func testUsesFocusedTerminalWhenTerminalIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused terminal") + return + } + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testFallsBackToTerminalWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected selected workspace setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused" + ) + } + + func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected workspace inheritance source to use last focused terminal across panes" + ) + } +} + +@MainActor +final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { + func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { + let manager = TabManager() + guard let workspace1 = manager.selectedWorkspace, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/ws-switch")) else { + XCTFail("Expected initial workspace and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let workspace2 = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, workspace2.id) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace1.id) + XCTAssertTrue(isFocusedPanelBrowser(in: workspace1)) + } + + func testReopenFallsBackToCurrentWorkspaceAndFocusesBrowserWhenOriginalWorkspaceDeleted() { + let manager = TabManager() + guard let originalWorkspace = manager.selectedWorkspace, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/deleted-ws")) else { + XCTFail("Expected initial workspace and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(originalWorkspace.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let currentWorkspace = manager.addWorkspace() + manager.closeWorkspace(originalWorkspace) + + XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) + XCTAssertFalse(manager.tabs.contains(where: { $0.id == originalWorkspace.id })) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) + XCTAssertTrue(isFocusedPanelBrowser(in: currentWorkspace)) + } + + func testReopenCollapsedSplitFromDifferentWorkspaceFocusesBrowser() { + let manager = TabManager() + guard let workspace1 = manager.selectedWorkspace, + let sourcePanelId = workspace1.focusedPanelId, + let splitBrowserId = manager.newBrowserSplit( + tabId: workspace1.id, + fromPanelId: sourcePanelId, + orientation: .horizontal, + insertFirst: false, + url: URL(string: "https://example.com/collapsed-split") + ) else { + XCTFail("Expected to create browser split") + return + } + + drainMainQueue() + XCTAssertTrue(workspace1.closePanel(splitBrowserId, force: true)) + drainMainQueue() + + let workspace2 = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, workspace2.id) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace1.id) + XCTAssertTrue(isFocusedPanelBrowser(in: workspace1)) + } + + func testReopenFromDifferentWorkspaceWinsAgainstSingleDeferredStaleFocus() { + let manager = TabManager() + guard let workspace1 = manager.selectedWorkspace, + let preReopenPanelId = workspace1.focusedPanelId, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-cross-ws")) else { + XCTFail("Expected initial workspace state and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let panelIdsBeforeReopen = Set(workspace1.panels.keys) + let workspace2 = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, workspace2.id) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + guard let reopenedPanelId = singleNewPanelId(in: workspace1, comparedTo: panelIdsBeforeReopen) else { + XCTFail("Expected reopened browser panel ID") + return + } + + // Simulate one delayed stale focus callback from the panel that was focused before reopen. + DispatchQueue.main.async { + workspace1.focusPanel(preReopenPanelId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace1.id) + XCTAssertEqual(workspace1.focusedPanelId, reopenedPanelId) + XCTAssertTrue(workspace1.panels[reopenedPanelId] is BrowserPanel) + } + + func testReopenInSameWorkspaceWinsAgainstSingleDeferredStaleFocus() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let preReopenPanelId = workspace.focusedPanelId, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-same-ws")) else { + XCTFail("Expected initial workspace state and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(workspace.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let panelIdsBeforeReopen = Set(workspace.panels.keys) + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + guard let reopenedPanelId = singleNewPanelId(in: workspace, comparedTo: panelIdsBeforeReopen) else { + XCTFail("Expected reopened browser panel ID") + return + } + + // Simulate one delayed stale focus callback from the panel that was focused before reopen. + DispatchQueue.main.async { + workspace.focusPanel(preReopenPanelId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace.id) + XCTAssertEqual(workspace.focusedPanelId, reopenedPanelId) + XCTAssertTrue(workspace.panels[reopenedPanelId] is BrowserPanel) + } + + private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool { + guard let focusedPanelId = workspace.focusedPanelId else { return false } + return workspace.panels[focusedPanelId] is BrowserPanel + } + + private func singleNewPanelId(in workspace: Workspace, comparedTo previousPanelIds: Set) -> UUID? { + let newPanelIds = Set(workspace.panels.keys).subtracting(previousPanelIds) + guard newPanelIds.count == 1 else { return nil } + return newPanelIds.first + } + + private func drainMainQueue() { + let expectation = expectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } +} + +@MainActor +final class WorkspacePanelGitBranchTests: XCTestCase { + private func drainMainQueue() { + let expectation = expectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser split to preserve pre-split focus" + ) + } + + func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let terminalSplitPanel = workspace.newTerminalSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected terminal split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal split to preserve pre-split focus" + ) + } + + func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: panelId) else { + XCTFail("Expected initial panel and pane") + return + } + + XCTAssertEqual(workspace.panels.count, 1) +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif + + guard let detached = workspace.detachSurface(panelId: panelId) else { + XCTFail("Expected detach of last surface to succeed") + return + } + + XCTAssertEqual(detached.panelId, panelId) + XCTAssertTrue( + workspace.panels.isEmpty, + "Detaching the last surface should not auto-create a replacement panel" + ) + XCTAssertNil(workspace.surfaceIdFromPanelId(panelId)) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0) + + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching during cross-workspace moves should not schedule delayed source focus reconciliation" + ) +#endif + + let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false) + XCTAssertEqual(restoredPanelId, panelId) + XCTAssertEqual(workspace.panels.count, 1) + } + + func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() { + let workspace = Workspace() + guard let originalPanelId = workspace.focusedPanelId, + let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else { + XCTFail("Expected two panels before detach") + return + } + + drainMainQueue() + drainMainQueue() +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif + + guard let detached = workspace.detachSurface(panelId: movedPanel.id) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.panelId, movedPanel.id) + XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel") + XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach") + + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching into another workspace should not enqueue delayed source focus reconciliation" + ) +#endif + } + + func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() { + let source = Workspace() + guard let panelId = source.focusedPanelId else { + XCTFail("Expected source focused panel") + return + } + + XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title")) + + guard let detached = source.detachSurface(panelId: panelId) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.cachedTitle, "detached-runtime-title") + XCTAssertNil(detached.customTitle) + XCTAssertEqual( + detached.title, + "detached-runtime-title", + "Detached transfer should carry the cached non-custom title" + ) + + let destination = Workspace() + guard let destinationPane = destination.bonsplitController.allPaneIds.first else { + XCTFail("Expected destination pane") + return + } + + let attachedPanelId = destination.attachDetachedSurface( + detached, + inPane: destinationPane, + focus: false + ) + XCTAssertEqual(attachedPanelId, panelId) + XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title") + + guard let attachedTabId = destination.surfaceIdFromPanelId(panelId), + let attachedTab = destination.bonsplitController.tab(attachedTabId) else { + XCTFail("Expected attached tab mapping") + return + } + XCTAssertEqual(attachedTab.title, "detached-runtime-title") + XCTAssertFalse(attachedTab.hasCustomTitle) + } + + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected focused pane for initial panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id), + let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id), + let splitTab = workspace.bonsplitController + .tabs(inPane: splitPaneId) + .first(where: { $0.id == splitTabId }) else { + XCTFail("Expected split pane/tab mapping") + return + } + + // Simulate one delayed stale split-selection callback from bonsplit. + DispatchQueue.main.async { + workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus split to reassert the pre-split focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.focusedPaneId, + originalPaneId, + "Expected focused pane to converge back to the pre-split pane" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to converge back to the pre-split focused panel" + ) + } + + func testBrowserSplitWithFocusFalseAllowsSubsequentExplicitFocusOnSplitPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + workspace.focusPanel(browserSplitPanel.id) + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + browserSplitPanel.id, + "Expected explicit focus intent to keep the split panel focused" + ) + } + + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { + let workspace = Workspace() + guard let firstPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false) + guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + + workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/bugfix", isDirty: true) + XCTAssertEqual(workspace.focusedPanelId, secondPanel.id, "Expected split panel to be focused") + XCTAssertEqual(workspace.gitBranch?.branch, "feature/bugfix") + XCTAssertEqual(workspace.gitBranch?.isDirty, true) + + XCTAssertTrue(workspace.closePanel(secondPanel.id, force: true), "Expected split panel close to succeed") + XCTAssertEqual(workspace.focusedPanelId, firstPanelId, "Expected surviving panel to become focused") + XCTAssertEqual(workspace.gitBranch?.branch, "main") + XCTAssertEqual(workspace.gitBranch?.isDirty, false) + } + + func testSidebarGitBranchesFollowLeftToRightSplitOrder() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false) + guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "feature/sidebar", isDirty: true) + + let ordered = workspace.sidebarGitBranchesInDisplayOrder() + XCTAssertEqual(ordered.map(\.branch), ["main", "feature/sidebar"]) + XCTAssertEqual(ordered.map(\.isDirty), [false, true]) + } + + func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() { + let workspace = Workspace() + guard let leftFirstPanelId = workspace.focusedPanelId, + let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId), + let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id), + let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false), + let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else { + XCTFail("Expected panes and panels for ordering test") + return + } + + XCTAssertTrue(workspace.reorderSurface(panelId: leftFirstPanelId, toIndex: 0)) + XCTAssertTrue(workspace.reorderSurface(panelId: leftSecondPanel.id, toIndex: 1)) + XCTAssertTrue(workspace.reorderSurface(panelId: rightFirstPanel.id, toIndex: 0)) + XCTAssertTrue(workspace.reorderSurface(panelId: rightSecondPanel.id, toIndex: 1)) + + workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false) + workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: false) + workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "main", isDirty: true) + workspace.updatePanelGitBranch(panelId: rightSecondPanel.id, branch: "feature/right", isDirty: false) + + XCTAssertEqual( + workspace.sidebarOrderedPanelIds(), + [leftFirstPanelId, leftSecondPanel.id, rightFirstPanel.id, rightSecondPanel.id] + ) + + let branches = workspace.sidebarGitBranchesInDisplayOrder() + XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"]) + XCTAssertEqual(branches.map(\.isDirty), [true, false, false]) + } + + func testClosingPaneDropsBranchesFromClosedSide() { + let workspace = Workspace() + guard let leftPanelId = workspace.focusedPanelId, + let leftPaneId = workspace.paneId(forPanelId: leftPanelId), + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected left/right split panes") + return + } + + workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "branch1", isDirty: false) + workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false) + + XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"]) + XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId)) + XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"]) + } +} + +final class SidebarBranchOrderingTests: XCTestCase { + + func testOrderedUniqueBranchesDedupesByNameAndMergesDirtyState() { + let first = UUID() + let second = UUID() + let third = UUID() + + let branches = SidebarBranchOrdering.orderedUniqueBranches( + orderedPanelIds: [first, second, third], + panelBranches: [ + first: SidebarGitBranchState(branch: "main", isDirty: false), + second: SidebarGitBranchState(branch: "feature", isDirty: false), + third: SidebarGitBranchState(branch: "main", isDirty: true) + ], + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false) + ) + + XCTAssertEqual( + branches, + [ + SidebarBranchOrdering.BranchEntry(name: "main", isDirty: true), + SidebarBranchOrdering.BranchEntry(name: "feature", isDirty: false) + ] + ) + } + + func testOrderedUniqueBranchesUsesFallbackWhenNoPanelBranchesExist() { + let branches = SidebarBranchOrdering.orderedUniqueBranches( + orderedPanelIds: [], + panelBranches: [:], + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: true) + ) + + XCTAssertEqual( + branches, + [SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)] + ) + } + + func testOrderedUniqueBranchDirectoryEntriesDedupesPairsAndMergesDirtyState() { + let first = UUID() + let second = UUID() + let third = UUID() + let fourth = UUID() + let fifth = UUID() + + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [first, second, third, fourth, fifth], + panelBranches: [ + first: SidebarGitBranchState(branch: "main", isDirty: false), + second: SidebarGitBranchState(branch: "feature", isDirty: false), + third: SidebarGitBranchState(branch: "main", isDirty: true), + fourth: SidebarGitBranchState(branch: "main", isDirty: false) + ], + panelDirectories: [ + first: "/repo/a", + second: "/repo/b", + third: "/repo/a", + fourth: "/repo/d", + fifth: "/repo/e" + ], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false) + ) + + XCTAssertEqual( + rows, + [ + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/a"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "feature", isDirty: false, directory: "/repo/b"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/d"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: nil, isDirty: false, directory: "/repo/e") + ] + ) + } + + func testOrderedUniqueBranchDirectoryEntriesUsesFallbackBranchWhenPanelBranchesMissing() { + let first = UUID() + let second = UUID() + + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [first, second], + panelBranches: [:], + panelDirectories: [ + first: "/repo/one", + second: "/repo/two" + ], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: true) + ) + + XCTAssertEqual( + rows, + [ + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/one"), + SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/two") + ] + ) + } + + func testOrderedUniqueBranchDirectoryEntriesFallsBackWhenNoPanelsExist() { + let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + orderedPanelIds: [], + panelBranches: [:], + panelDirectories: [:], + defaultDirectory: "/repo/default", + fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: false) + ) + + XCTAssertEqual( + rows, + [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")] + ) + } +} + @MainActor final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { func testRequestPersistsUntilAcknowledged() { @@ -1204,6 +3941,63 @@ final class FinderServicePathResolverTests: XCTestCase { } } +final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { + private func environment( + existingPaths: Set, + homeDirectoryPath: String = "/Users/tester" + ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { + TerminalDirectoryOpenTarget.DetectionEnvironment( + homeDirectoryPath: homeDirectoryPath, + fileExistsAtPath: { existingPaths.contains($0) } + ) + } + + func testAvailableTargetsDetectSystemApplications() { + let env = environment( + existingPaths: [ + "/Applications/Visual Studio Code.app", + "/System/Library/CoreServices/Finder.app", + "/System/Applications/Utilities/Terminal.app", + "/Applications/Zed Preview.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.finder)) + XCTAssertTrue(availableTargets.contains(.terminal)) + XCTAssertTrue(availableTargets.contains(.zed)) + XCTAssertFalse(availableTargets.contains(.cursor)) + } + + func testAvailableTargetsFallbackToUserApplications() { + let env = environment( + existingPaths: [ + "/Users/tester/Applications/Cursor.app", + "/Users/tester/Applications/Warp.app", + "/Users/tester/Applications/Android Studio.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.cursor)) + XCTAssertTrue(availableTargets.contains(.warp)) + XCTAssertTrue(availableTargets.contains(.androidStudio)) + XCTAssertFalse(availableTargets.contains(.vscode)) + } + + func testITerm2DetectsLegacyBundleName() { + let env = environment(existingPaths: ["/Applications/iTerm.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env)) + } + + func testCommandPaletteShortcutsExcludeGenericIDEEntry() { + let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets + XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" })) + XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" })) + } +} + final class BrowserSearchEngineTests: XCTestCase { func testGoogleSearchURL() throws { let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world")) @@ -2331,6 +5125,8 @@ final class WindowTerminalHostViewTests: XCTestCase { } } + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() { let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) @@ -2345,6 +5141,431 @@ final class WindowTerminalHostViewTests: XCTestCase { XCTAssertTrue(host.hitTest(NSPoint(x: 25, y: 20)) === child) XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100))) } + + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + splitView.dividerStyle = .thin + let splitDelegate = BonsplitMockSplitDelegate() + splitView.delegate = splitDelegate + let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) + let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) + splitView.addSubview(first) + splitView.addSubview(second) + contentView.addSubview(splitView) + splitView.setPosition(1, ofDividerAt: 0) + splitView.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let host = WindowTerminalHostView(frame: contentView.bounds) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + contentView.addSubview(host) + + let dividerPointInSplit = NSPoint( + x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), + y: splitView.bounds.midY + ) + let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5) + XCTAssertNil( + host.hitTest(dividerPointInHost), + "Host view must pass through divider hits even when one pane is nearly collapsed" + ) + + let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) + let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) + let contentPointInHost = host.convert(contentPointInWindow, from: nil) + XCTAssertTrue(host.hitTest(contentPointInHost) === child) + } +} + +@MainActor +final class WindowBrowserHostViewTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} + + func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let splitView = NSSplitView(frame: contentView.bounds) + splitView.autoresizingMask = [.width, .height] + splitView.isVertical = true + splitView.dividerStyle = .thin + let splitDelegate = BonsplitMockSplitDelegate() + splitView.delegate = splitDelegate + let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)) + let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height)) + splitView.addSubview(first) + splitView.addSubview(second) + contentView.addSubview(splitView) + splitView.setPosition(1, ofDividerAt: 0) + splitView.adjustSubviews() + contentView.layoutSubtreeIfNeeded() + + let host = WindowBrowserHostView(frame: contentView.bounds) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + contentView.addSubview(host) + + let dividerPointInSplit = NSPoint( + x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5), + y: splitView.bounds.midY + ) + let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil) + let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) + XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5) + XCTAssertNil( + host.hitTest(dividerPointInHost), + "Browser host must pass through divider hits even when one pane is nearly collapsed" + ) + + let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY) + let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil) + let contentPointInHost = host.convert(contentPointInWindow, from: nil) + XCTAssertTrue(host.hitTest(contentPointInHost) === child) + } +} + +@MainActor +final class WindowDragHandleHitTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class HostContainerView: NSView {} + private final class PassiveHostContainerView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + return super.hitTest(point) ?? self + } + } + + func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle), + "Empty titlebar space should drag the window" + ) + } + + func testDragHandleYieldsWhenSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + container.addSubview(folderIconHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle), + "Interactive titlebar controls should receive the mouse event" + ) + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle)) + } + + func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + hidden.isHidden = true + container.addSubview(hidden) + + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle)) + } + + func testDragHandleDoesNotCaptureOutsideBounds() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle)) + } + + func testPassiveHostingTopHitClassification() { + XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero))) + XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero))) + } + + func testDragHandleIgnoresPassiveHostSiblingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + container.addSubview(passiveHost) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle), + "Passive host wrappers should not block titlebar drag capture" + ) + } + + func testDragHandleRespectsInteractiveChildInsidePassiveHost() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + passiveHost.addSubview(folderControl) + container.addSubview(passiveHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle), + "Interactive controls inside passive host wrappers should still receive hits" + ) + } +} + +@MainActor +final class DraggableFolderHitTests: XCTestCase { + func testFolderHitTestReturnsContainerWhenInsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else { + XCTFail("Expected folder icon to capture inside hit") + return + } + XCTAssertTrue(hit === folderView) + } + + func testFolderHitTestReturnsNilOutsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8))) + } + + func testFolderIconDisablesWindowMoveBehavior() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertFalse(folderView.mouseDownCanMoveWindow) + } +} + +@MainActor +final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { + func testLeadingInsetViewDoesNotParticipateInHitTesting() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10))) + } + + func testLeadingInsetViewCannotMoveWindowViaMouseDown() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertFalse(view.mouseDownCanMoveWindow) + } +} + +@MainActor +final class FolderWindowMoveSuppressionTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testSuppressionDisablesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, true) + XCTAssertFalse(window.isMovable) + } + + func testSuppressionPreservesAlreadyImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testRestoreAppliesPreviousMovableState() { + let window = makeWindow() + window.isMovable = false + + restoreWindowDragging(window: window, previousMovableState: true) + XCTAssertTrue(window.isMovable) + + restoreWindowDragging(window: window, previousMovableState: false) + XCTAssertFalse(window.isMovable) + } + + func testWindowDragSuppressionDepthLifecycle() { + let window = makeWindow() + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testWindowDragSuppressionIsReferenceCounted() { + let window = makeWindow() + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(beginWindowDragSuppression(window: window), 2) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 2) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testTemporaryWindowMovableEnableRestoresImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testTemporaryWindowMovableEnablePreservesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, true) + XCTAssertTrue(window.isMovable) + } +} + +@MainActor +final class WindowMoveSuppressionHitPathTests: XCTestCase { + private func makeWindowWithContentView() -> (NSWindow, NSView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + return (window, contentView) + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + func testSuppressionHitPathRecognizesFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView)) + } + + func testSuppressionHitPathRecognizesDescendantOfFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + let child = NSView(frame: .zero) + folderView.addSubview(child) + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child)) + } + + func testSuppressionHitPathIgnoresUnrelatedViews() { + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero))) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil)) + } + + func testSuppressionEventPathRecognizesFolderHitInsideWindow() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16) + contentView.addSubview(folderView) + + let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window) + + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event)) + } + + func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + contentView.addSubview(plainView) + + let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down)) + + let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged)) + } } @MainActor @@ -2363,10 +5584,182 @@ final class GhosttySurfaceOverlayTests: XCTestCase { state = hostedView.debugInactiveOverlayState() XCTAssertTrue(state.isHidden) } + + func testWindowResignKeyClearsFocusedTerminalFirstResponder() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + ) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + hostedView.moveFocus() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue( + hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to be first responder before window blur" + ) + + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.isSurfaceViewFirstResponder(), + "Window blur should force terminal surface to resign first responder" + ) + } + + func testSearchOverlayMountsAndUnmountsWithSearchState() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + XCTAssertFalse(hostedView.debugHasSearchOverlay()) + + let searchState = TerminalSurface.SearchState(needle: "example") + hostedView.setSearchOverlay(searchState: searchState) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + hostedView.setSearchOverlay(searchState: nil) + XCTAssertFalse(hostedView.debugHasSearchOverlay()) + } + + func testSearchOverlayMountDoesNotRetainTerminalSurface() { + weak var weakSurface: TerminalSurface? + + let hostedView: GhosttySurfaceScrollView = { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + weakSurface = surface + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "retain-check")) + return hostedView + }() + + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + XCTAssertNil(weakSurface, "Mounted search overlay must not retain TerminalSurface") + } + + func testSearchOverlaySurvivesPortalRebindDuringSplitLikeChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowTerminalPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchorA = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 140)) + let anchorB = NSView(frame: NSRect(x: 220, y: 20, width: 180, height: 140)) + contentView.addSubview(anchorA) + contentView.addSubview(anchorB) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split")) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchorB, visibleInUI: true) + XCTAssertTrue( + hostedView.debugHasSearchOverlay(), + "Split-like anchor churn should not unmount terminal search overlay" + ) + } + + func testSearchOverlaySurvivesPortalVisibilityToggleDuringWorkspaceSwitchLikeChurn() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowTerminalPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 220, height: 160)) + contentView.addSubview(anchor) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace")) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: false) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true) + XCTAssertTrue( + hostedView.debugHasSearchOverlay(), + "Workspace-switch-like visibility toggles should not unmount terminal search overlay" + ) + } } @MainActor final class TerminalWindowPortalLifecycleTests: 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 testPortalHostInstallsAboveContentViewForVisibility() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -2556,6 +5949,50 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Promoting z-priority should bring an already-visible terminal to front" ) } + + func testHiddenPortalDefersRevealUntilFrameHasUsableSize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let portal = WindowTerminalPortal(window: window) + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220)) + contentView.addSubview(anchor) + + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible") + + // Collapse to a tiny frame first. + anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal") + + // Then restore to a non-zero but still too-small frame. It should remain hidden. + anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue( + hosted.isHidden, + "Portal should defer reveal until geometry reaches a usable size" + ) + + // Once the frame is large enough again, reveal should resume. + anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable") + } } @MainActor @@ -2803,6 +6240,34 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) } + + func testOpenCommandInterceptionDefaultsToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) + } } final class TerminalOpenURLTargetResolutionTests: XCTestCase { @@ -2875,6 +6340,38 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } } +final class BrowserExternalNavigationSchemeTests: XCTestCase { + func testCustomAppSchemesOpenExternally() throws { + let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc")) + let slack = try XCTUnwrap(URL(string: "slack://open")) + let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join")) + let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com")) + + XCTAssertTrue(browserShouldOpenURLExternally(discord)) + XCTAssertTrue(browserShouldOpenURLExternally(slack)) + XCTAssertTrue(browserShouldOpenURLExternally(zoom)) + XCTAssertTrue(browserShouldOpenURLExternally(mailto)) + } + + func testEmbeddedBrowserSchemesStayInWebView() throws { + let https = try XCTUnwrap(URL(string: "https://example.com")) + let http = try XCTUnwrap(URL(string: "http://example.com")) + let about = try XCTUnwrap(URL(string: "about:blank")) + let data = try XCTUnwrap(URL(string: "data:text/plain,hello")) + let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000")) + let javascript = try XCTUnwrap(URL(string: "javascript:void(0)")) + let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page")) + + XCTAssertFalse(browserShouldOpenURLExternally(https)) + XCTAssertFalse(browserShouldOpenURLExternally(http)) + XCTAssertFalse(browserShouldOpenURLExternally(about)) + XCTAssertFalse(browserShouldOpenURLExternally(data)) + XCTAssertFalse(browserShouldOpenURLExternally(blob)) + XCTAssertFalse(browserShouldOpenURLExternally(javascript)) + XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) + } +} + final class BrowserHostWhitelistTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! @@ -2966,3 +6463,235 @@ final class BrowserHostWhitelistTests: XCTestCase { XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults)) } } + +final class TerminalControllerSidebarDedupeTests: XCTestCase { + func testShouldReplaceStatusEntryReturnsFalseForUnchangedPayload() { + let current = SidebarStatusEntry( + key: "agent", + value: "idle", + icon: "bolt", + color: "#ffffff", + timestamp: Date(timeIntervalSince1970: 123) + ) + XCTAssertFalse( + TerminalController.shouldReplaceStatusEntry( + current: current, + key: "agent", + value: "idle", + icon: "bolt", + color: "#ffffff" + ) + ) + } + + func testShouldReplaceStatusEntryReturnsTrueWhenValueChanges() { + let current = SidebarStatusEntry( + key: "agent", + value: "idle", + icon: "bolt", + color: "#ffffff", + timestamp: Date(timeIntervalSince1970: 123) + ) + XCTAssertTrue( + TerminalController.shouldReplaceStatusEntry( + current: current, + key: "agent", + value: "running", + icon: "bolt", + color: "#ffffff" + ) + ) + } + + func testShouldReplaceProgressReturnsFalseForUnchangedPayload() { + XCTAssertFalse( + TerminalController.shouldReplaceProgress( + current: SidebarProgressState(value: 0.42, label: "indexing"), + value: 0.42, + label: "indexing" + ) + ) + } + + func testShouldReplaceGitBranchReturnsFalseForUnchangedPayload() { + XCTAssertFalse( + TerminalController.shouldReplaceGitBranch( + current: SidebarGitBranchState(branch: "main", isDirty: true), + branch: "main", + isDirty: true + ) + ) + } + + func testShouldReplacePortsIgnoresOrderAndDuplicates() { + XCTAssertFalse( + TerminalController.shouldReplacePorts( + current: [9229, 3000], + next: [3000, 9229, 3000] + ) + ) + XCTAssertTrue( + TerminalController.shouldReplacePorts( + current: [9229, 3000], + next: [3000] + ) + ) + } + + func testExplicitSocketScopeParsesValidUUIDTabAndPanel() { + let workspaceId = UUID() + let panelId = UUID() + let scope = TerminalController.explicitSocketScope( + options: [ + "tab": workspaceId.uuidString, + "panel": panelId.uuidString + ] + ) + XCTAssertEqual(scope?.workspaceId, workspaceId) + XCTAssertEqual(scope?.panelId, panelId) + } + + func testExplicitSocketScopeAcceptsSurfaceAlias() { + let workspaceId = UUID() + let panelId = UUID() + let scope = TerminalController.explicitSocketScope( + options: [ + "tab": workspaceId.uuidString, + "surface": panelId.uuidString + ] + ) + XCTAssertEqual(scope?.workspaceId, workspaceId) + XCTAssertEqual(scope?.panelId, panelId) + } + + func testExplicitSocketScopeRejectsMissingOrInvalidValues() { + XCTAssertNil(TerminalController.explicitSocketScope(options: [:])) + XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": "workspace:1", "panel": UUID().uuidString])) + XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": UUID().uuidString, "panel": "surface:1"])) + } + + func testNormalizeReportedDirectoryTrimsWhitespace() { + XCTAssertEqual( + TerminalController.normalizeReportedDirectory(" /Users/cmux/project "), + "/Users/cmux/project" + ) + } + + func testNormalizeReportedDirectoryResolvesFileURL() { + XCTAssertEqual( + TerminalController.normalizeReportedDirectory("file:///Users/cmux/project"), + "/Users/cmux/project" + ) + } + + func testNormalizeReportedDirectoryLeavesInvalidURLTrimmed() { + XCTAssertEqual( + TerminalController.normalizeReportedDirectory(" file://bad host "), + "file://bad host" + ) + } +} + +final class TerminalControllerSocketTextChunkTests: XCTestCase { + func testSocketTextChunksReturnsSingleChunkForPlainText() { + XCTAssertEqual( + TerminalController.socketTextChunks("echo hello"), + [.text("echo hello")] + ) + } + + func testSocketTextChunksSplitsControlScalars() { + XCTAssertEqual( + TerminalController.socketTextChunks("abc\rdef\tghi"), + [ + .text("abc"), + .control("\r".unicodeScalars.first!), + .text("def"), + .control("\t".unicodeScalars.first!), + .text("ghi") + ] + ) + } + + func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() { + XCTAssertEqual( + TerminalController.socketTextChunks("\r\n\t"), + [ + .control("\r".unicodeScalars.first!), + .control("\n".unicodeScalars.first!), + .control("\t".unicodeScalars.first!) + ] + ) + } +} + +final class BrowserOmnibarFocusPolicyTests: XCTestCase { + func testReacquiresFocusWhenWebViewSuppressionIsActiveAndNextResponderIsNotAnotherTextField() { + XCTAssertTrue( + browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: true, + nextResponderIsOtherTextField: false + ) + ) + } + + func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: true, + nextResponderIsOtherTextField: true + ) + ) + } + + func testDoesNotReacquireFocusWhenWebViewSuppressionIsInactive() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: false, + nextResponderIsOtherTextField: false + ) + ) + } +} + +final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { + func testImmediateStateUpdateAllowedWhenHostNotInWindow() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: false, + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: true, + isBoundToCurrentHost: true + ) + ) + } + + func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() { + XCTAssertFalse( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: false, + isBoundToCurrentHost: false + ) + ) + } +} diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index e2978e55..220767ba 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -162,7 +162,70 @@ final class GhosttyConfigTests: XCTestCase { ) } - func testClaudeCodeIntegrationDefaultsToDisabledWhenUnset() { + func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() { + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .unscoped, + incomingScope: .app + ) + ) + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .app, + incomingScope: .surface + ) + ) + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .surface + ) + ) + XCTAssertFalse( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .app + ) + ) + XCTAssertFalse( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .unscoped + ) + ) + } + + func testAppearanceChangeReloadsWhenColorSchemeChanges() { + XCTAssertTrue( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .dark, + currentColorScheme: .light + ) + ) + XCTAssertTrue( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: nil, + currentColorScheme: .dark + ) + ) + } + + func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() { + XCTAssertFalse( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .light, + currentColorScheme: .light + ) + ) + XCTAssertFalse( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .dark, + currentColorScheme: .dark + ) + ) + } + + func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated user defaults suite") @@ -173,7 +236,7 @@ final class GhosttyConfigTests: XCTestCase { } defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey) - XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) + XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults)) } func testClaudeCodeIntegrationRespectsStoredPreference() { @@ -208,6 +271,75 @@ final class GhosttyConfigTests: XCTestCase { } } +final class WorkspaceChromeThemeTests: XCTestCase { + func testResolvedChromeColorsUsesLightGhosttyBackground() { + guard let backgroundColor = NSColor(hex: "#FDF6E3") else { + XCTFail("Expected valid test color") + return + } + + let colors = Workspace.resolvedChromeColors(from: backgroundColor) + XCTAssertEqual(colors.backgroundHex, "#FDF6E3") + XCTAssertNil(colors.borderHex) + } + + func testResolvedChromeColorsUsesDarkGhosttyBackground() { + guard let backgroundColor = NSColor(hex: "#272822") else { + XCTFail("Expected valid test color") + return + } + + let colors = Workspace.resolvedChromeColors(from: backgroundColor) + XCTAssertEqual(colors.backgroundHex, "#272822") + XCTAssertNil(colors.borderHex) + } +} + +final class WorkspaceAppearanceConfigResolutionTests: XCTestCase { + func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() { + guard let loadedBackground = NSColor(hex: "#112233"), + let runtimeBackground = NSColor(hex: "#FDF6E3"), + let loadedForeground = NSColor(hex: "#ABCDEF") else { + XCTFail("Expected valid test colors") + return + } + + var loaded = GhosttyConfig() + loaded.backgroundColor = loadedBackground + loaded.foregroundColor = loadedForeground + loaded.unfocusedSplitOpacity = 0.42 + + let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig( + loadConfig: { loaded }, + defaultBackground: { runtimeBackground } + ) + + XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3") + XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF") + XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001) + } + + func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() { + guard let loadedBackground = NSColor(hex: "#112233"), + let runtimeBackground = NSColor(hex: "#FDF6E3"), + let explicitOverride = NSColor(hex: "#272822") else { + XCTFail("Expected valid test colors") + return + } + + var loaded = GhosttyConfig() + loaded.backgroundColor = loadedBackground + + let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig( + backgroundOverride: explicitOverride, + loadConfig: { loaded }, + defaultBackground: { runtimeBackground } + ) + + XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822") + } +} + final class NotificationBurstCoalescerTests: XCTestCase { func testSignalsInSameBurstFlushOnce() { let coalescer = NotificationBurstCoalescer(delay: 0.01) @@ -271,6 +403,133 @@ final class NotificationBurstCoalescerTests: XCTestCase { } } +final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase { + func testSignalCoalescesBurstToLatestBackground() { + guard let dark = NSColor(hex: "#272822"), + let light = NSColor(hex: "#FDF6E3") else { + XCTFail("Expected valid test colors") + return + } + + let expectation = expectation(description: "coalesced notification") + expectation.expectedFulfillmentCount = 1 + var postedUserInfos: [[AnyHashable: Any]] = [] + + let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher( + delay: 0.01, + postNotification: { userInfo in + postedUserInfos.append(userInfo) + expectation.fulfill() + } + ) + + DispatchQueue.main.async { + dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark") + dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light") + } + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(postedUserInfos.count, 1) + XCTAssertEqual( + (postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(), + "#FDF6E3" + ) + XCTAssertEqual( + postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]), + 0.75, + accuracy: 0.0001 + ) + XCTAssertEqual( + (postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value, + 2 + ) + XCTAssertEqual( + postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String, + "test.light" + ) + } + + func testSignalAcrossSeparateBurstsPostsMultipleNotifications() { + guard let dark = NSColor(hex: "#272822"), + let light = NSColor(hex: "#FDF6E3") else { + XCTFail("Expected valid test colors") + return + } + + let expectation = expectation(description: "two notifications") + expectation.expectedFulfillmentCount = 2 + var postedHexes: [String] = [] + + let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher( + delay: 0.01, + postNotification: { userInfo in + let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + postedHexes.append(hex) + expectation.fulfill() + } + ) + + DispatchQueue.main.async { + dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light") + } + } + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"]) + } + + private func postedOpacity(from value: Any?) -> Double { + if let value = value as? Double { + return value + } + if let value = value as? NSNumber { + return value.doubleValue + } + XCTFail("Expected background opacity payload") + return -1 + } +} + +final class RecentlyClosedBrowserStackTests: XCTestCase { + func testPopReturnsEntriesInLIFOOrder() { + var stack = RecentlyClosedBrowserStack(capacity: 20) + stack.push(makeSnapshot(index: 1)) + stack.push(makeSnapshot(index: 2)) + stack.push(makeSnapshot(index: 3)) + + XCTAssertEqual(stack.pop()?.originalTabIndex, 3) + XCTAssertEqual(stack.pop()?.originalTabIndex, 2) + XCTAssertEqual(stack.pop()?.originalTabIndex, 1) + XCTAssertNil(stack.pop()) + } + + func testPushDropsOldestEntriesWhenCapacityExceeded() { + var stack = RecentlyClosedBrowserStack(capacity: 3) + for index in 1...5 { + stack.push(makeSnapshot(index: index)) + } + + XCTAssertEqual(stack.pop()?.originalTabIndex, 5) + XCTAssertEqual(stack.pop()?.originalTabIndex, 4) + XCTAssertEqual(stack.pop()?.originalTabIndex, 3) + XCTAssertNil(stack.pop()) + } + + private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot { + ClosedBrowserPanelRestoreSnapshot( + workspaceId: UUID(), + url: URL(string: "https://example.com/\(index)"), + originalPaneId: UUID(), + originalTabIndex: index, + fallbackSplitOrientation: .horizontal, + fallbackSplitInsertFirst: false, + fallbackAnchorPaneId: UUID() + ) + } +} + final class TabManagerNotificationOrderingSourceTests: XCTestCase { func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws { let projectRoot = findProjectRoot() @@ -316,3 +575,168 @@ final class TabManagerNotificationOrderingSourceTests: XCTestCase { return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) } } + +final class SocketControlSettingsTests: XCTestCase { + func testMigrateModeSupportsExpandedSocketModes() { + XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off) + XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly) + XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation) + XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password) + XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll) + + // Legacy aliases + XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation) + XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll) + } + + func testSocketModePermissions() { + XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600) + XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600) + XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600) + XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600) + XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666) + } + + func testInvalidEnvSocketModeDoesNotOverrideUserMode() { + XCTAssertNil( + SocketControlSettings.envOverrideMode( + environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"] + ) + ) + XCTAssertEqual( + SocketControlSettings.effectiveMode( + userMode: .password, + environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"] + ), + .password + ) + } + + func testStableReleaseIgnoresAmbientSocketOverrideByDefault() { + let path = SocketControlSettings.socketPath( + environment: [ + "CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock", + ], + bundleIdentifier: "com.cmuxterm.app", + isDebugBuild: false + ) + + XCTAssertEqual(path, "/tmp/cmux.sock") + } + + func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() { + let path = SocketControlSettings.socketPath( + environment: [ + "CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock", + ], + bundleIdentifier: "com.cmuxterm.app.nightly", + isDebugBuild: false + ) + + XCTAssertEqual(path, "/tmp/cmux-nightly.sock") + } + + func testDebugBundleHonorsSocketOverrideWithoutOptInFlag() { + let path = SocketControlSettings.socketPath( + environment: [ + "CMUX_SOCKET_PATH": "/tmp/cmux-debug-my-tag.sock", + ], + bundleIdentifier: "com.cmuxterm.app.debug.my-tag", + isDebugBuild: false + ) + + XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock") + } + + func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() { + let path = SocketControlSettings.socketPath( + environment: [ + "CMUX_SOCKET_PATH": "/tmp/cmux-staging-my-tag.sock", + ], + bundleIdentifier: "com.cmuxterm.app.staging.my-tag", + isDebugBuild: false + ) + + XCTAssertEqual(path, "/tmp/cmux-staging-my-tag.sock") + } + + func testStableReleaseCanOptInToSocketOverride() { + let path = SocketControlSettings.socketPath( + environment: [ + "CMUX_SOCKET_PATH": "/tmp/cmux-debug-forced.sock", + "CMUX_ALLOW_SOCKET_OVERRIDE": "1", + ], + bundleIdentifier: "com.cmuxterm.app", + isDebugBuild: false + ) + + XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock") + } + + func testDefaultSocketPathByChannel() { + XCTAssertEqual( + SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false), + "/tmp/cmux.sock" + ) + XCTAssertEqual( + SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false), + "/tmp/cmux-nightly.sock" + ) + XCTAssertEqual( + SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false), + "/tmp/cmux-debug.sock" + ) + XCTAssertEqual( + SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false), + "/tmp/cmux-staging.sock" + ) + } +} + +final class PostHogAnalyticsPropertiesTests: XCTestCase { + func testDailyActivePropertiesIncludeVersionAndBuild() { + let properties = PostHogAnalytics.dailyActiveProperties( + dayUTC: "2026-02-21", + reason: "didBecomeActive", + infoDictionary: [ + "CFBundleShortVersionString": "0.31.0", + "CFBundleVersion": "230", + ] + ) + + XCTAssertEqual(properties["day_utc"] as? String, "2026-02-21") + XCTAssertEqual(properties["reason"] as? String, "didBecomeActive") + XCTAssertEqual(properties["app_version"] as? String, "0.31.0") + XCTAssertEqual(properties["app_build"] as? String, "230") + } + + func testSuperPropertiesIncludePlatformVersionAndBuild() { + let properties = PostHogAnalytics.superProperties( + infoDictionary: [ + "CFBundleShortVersionString": "0.31.0", + "CFBundleVersion": "230", + ] + ) + + XCTAssertEqual(properties["platform"] as? String, "cmuxterm") + XCTAssertEqual(properties["app_version"] as? String, "0.31.0") + XCTAssertEqual(properties["app_build"] as? String, "230") + } + + func testPropertiesOmitVersionFieldsWhenUnavailable() { + let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:]) + XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm") + XCTAssertNil(superProperties["app_version"]) + XCTAssertNil(superProperties["app_build"]) + + let dailyProperties = PostHogAnalytics.dailyActiveProperties( + dayUTC: "2026-02-21", + reason: "activeTimer", + infoDictionary: [:] + ) + XCTAssertEqual(dailyProperties["day_utc"] as? String, "2026-02-21") + XCTAssertEqual(dailyProperties["reason"] as? String, "activeTimer") + XCTAssertNil(dailyProperties["app_version"]) + XCTAssertNil(dailyProperties["app_build"]) + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift new file mode 100644 index 00000000..cd98ab5a --- /dev/null +++ b/cmuxTests/SessionPersistenceTests.swift @@ -0,0 +1,645 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SessionPersistenceTests: XCTestCase { + func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + + let loaded = SessionPersistenceStore.load(fileURL: snapshotURL) + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion) + XCTAssertEqual(loaded?.windows.count, 1) + XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs) + let frame = try XCTUnwrap(loaded?.windows.first?.frame) + XCTAssertEqual(frame.x, 10, accuracy: 0.001) + XCTAssertEqual(frame.y, 20, accuracy: 0.001) + XCTAssertEqual(frame.width, 900, accuracy: 0.001) + XCTAssertEqual(frame.height, 700, accuracy: 0.001) + XCTAssertEqual(loaded?.windows.first?.display?.displayID, 42) + let visibleFrame = try XCTUnwrap(loaded?.windows.first?.display?.visibleFrame) + XCTAssertEqual(visibleFrame.y, 25, accuracy: 0.001) + } + + func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + snapshot.windows[0].tabManager.workspaces[0].customColor = "#C0392B" + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + + let loaded = SessionPersistenceStore.load(fileURL: snapshotURL) + XCTAssertEqual( + loaded?.windows.first?.tabManager.workspaces.first?.customColor, + "#C0392B" + ) + } + + func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws { + var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + snapshot.windows[0].tabManager.workspaces[0].customColor = nil + + let encoder = JSONEncoder() + let data = try encoder.encode(snapshot) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertFalse(json.contains("\"customColor\"")) + + let decoded = try JSONDecoder().decode(AppSessionSnapshot.self, from: data) + XCTAssertNil(decoded.windows.first?.tabManager.workspaces.first?.customColor) + } + + func testLoadRejectsSchemaVersionMismatch() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + XCTAssertTrue(SessionPersistenceStore.save(makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1), fileURL: snapshotURL)) + + XCTAssertNil(SessionPersistenceStore.load(fileURL: snapshotURL)) + } + + func testDefaultSnapshotPathSanitizesBundleIdentifier() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let path = SessionPersistenceStore.defaultSnapshotFileURL( + bundleIdentifier: "com.example/unsafe id", + appSupportDirectory: tempDir + ) + + XCTAssertNotNil(path) + XCTAssertTrue(path?.path.contains("com.example_unsafe_id") == true) + } + + func testRestorePolicySkipsWhenLaunchHasExplicitArguments() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "--window", "window:1"], + environment: [:] + ) + + XCTAssertFalse(shouldRestore) + } + + func testRestorePolicyAllowsFinderStyleLaunchArgumentsOnly() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "-psn_0_12345"], + environment: [:] + ) + + XCTAssertTrue(shouldRestore) + } + + func testRestorePolicySkipsWhenRunningUnderXCTest() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux"], + environment: ["XCTestConfigurationFilePath": "/tmp/xctest.xctestconfiguration"] + ) + + XCTAssertFalse(shouldRestore) + } + + func testSidebarWidthSanitizationClampsToPolicyRange() { + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(-20), + SessionPersistencePolicy.minimumSidebarWidth, + accuracy: 0.001 + ) + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(10_000), + SessionPersistencePolicy.maximumSidebarWidth, + accuracy: 0.001 + ) + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(nil), + SessionPersistencePolicy.defaultSidebarWidth, + accuracy: 0.001 + ) + } + + func testSessionRectSnapshotEncodesXYWidthHeightKeys() throws { + let snapshot = SessionRectSnapshot(x: 101.25, y: 202.5, width: 903.75, height: 704.5) + let data = try JSONEncoder().encode(snapshot) + let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Double]) + + XCTAssertEqual(Set(object.keys), Set(["x", "y", "width", "height"])) + XCTAssertEqual(try XCTUnwrap(object["x"]), 101.25, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["y"]), 202.5, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["width"]), 903.75, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(object["height"]), 704.5, accuracy: 0.001) + } + + func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws { + let source = SessionBrowserPanelSnapshot( + urlString: "https://example.com/current", + shouldRenderWebView: true, + pageZoom: 1.2, + developerToolsVisible: true, + backHistoryURLStrings: [ + "https://example.com/a", + "https://example.com/b" + ], + forwardHistoryURLStrings: [ + "https://example.com/d" + ] + ) + + let data = try JSONEncoder().encode(source) + let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data) + XCTAssertEqual(decoded.urlString, source.urlString) + XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings) + XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings) + } + + func testSessionBrowserPanelSnapshotHistoryDecodesWhenKeysAreMissing() throws { + let json = """ + { + "urlString": "https://example.com/current", + "shouldRenderWebView": true, + "pageZoom": 1.0, + "developerToolsVisible": false + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json) + XCTAssertEqual(decoded.urlString, "https://example.com/current") + XCTAssertNil(decoded.backHistoryURLStrings) + XCTAssertNil(decoded.forwardHistoryURLStrings) + } + + func testScrollbackReplayEnvironmentWritesReplayFile() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: "line one\nline two\n", + tempDirectory: tempDir + ) + + let path = environment[SessionScrollbackReplayStore.environmentKey] + XCTAssertNotNil(path) + XCTAssertTrue(path?.hasPrefix(tempDir.path) == true) + + guard let path else { return } + let contents = try? String(contentsOfFile: path, encoding: .utf8) + XCTAssertEqual(contents, "line one\nline two\n") + } + + func testScrollbackReplayEnvironmentSkipsWhitespaceOnlyContent() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: " \n\t ", + tempDirectory: tempDir + ) + + XCTAssertTrue(environment.isEmpty) + } + + func testScrollbackReplayEnvironmentPreservesANSIColorSequences() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let red = "\u{001B}[31m" + let reset = "\u{001B}[0m" + let source = "\(red)RED\(reset)\n" + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: source, + tempDirectory: tempDir + ) + + guard let path = environment[SessionScrollbackReplayStore.environmentKey] else { + XCTFail("Expected replay file path") + return + } + + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { + XCTFail("Expected replay file contents") + return + } + + XCTAssertTrue(contents.contains("\(red)RED\(reset)")) + XCTAssertTrue(contents.hasPrefix(reset)) + XCTAssertTrue(contents.hasSuffix(reset)) + } + + func testTruncatedScrollbackAvoidsLeadingPartialANSICSISequence() { + let maxChars = SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + let source = "\u{001B}[31m" + + String(repeating: "X", count: maxChars - 7) + + "\u{001B}[0m" + + guard let truncated = SessionPersistencePolicy.truncatedScrollback(source) else { + XCTFail("Expected truncated scrollback") + return + } + + XCTAssertFalse(truncated.hasPrefix("31m")) + XCTAssertFalse(truncated.hasPrefix("[31m")) + XCTAssertFalse(truncated.hasPrefix("m")) + } + + func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() { + XCTAssertEqual( + TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"), + "/tmp/cmux-screen.txt" + ) + XCTAssertEqual( + TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "), + "/tmp/cmux-screen.txt" + ) + } + + func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() { + XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt")) + XCTAssertNil(TerminalController.normalizedExportedScreenPath(" ")) + XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil)) + } + + func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() { + let tempRoot = URL(fileURLWithPath: "/tmp") + .appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true) + let tempFile = tempRoot + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("screen.txt", isDirectory: false) + let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt") + + XCTAssertTrue( + TerminalController.shouldRemoveExportedScreenDirectory( + fileURL: tempFile, + temporaryDirectory: tempRoot + ) + ) + XCTAssertFalse( + TerminalController.shouldRemoveExportedScreenDirectory( + fileURL: outsideFile, + temporaryDirectory: tempRoot + ) + ) + } + + func testShouldRemoveExportedScreenFileOnlyWithinTemporaryRoot() { + let tempRoot = URL(fileURLWithPath: "/tmp") + .appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true) + let tempFile = tempRoot + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("screen.txt", isDirectory: false) + let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt") + + XCTAssertTrue( + TerminalController.shouldRemoveExportedScreenFile( + fileURL: tempFile, + temporaryDirectory: tempRoot + ) + ) + XCTAssertFalse( + TerminalController.shouldRemoveExportedScreenFile( + fileURL: outsideFile, + temporaryDirectory: tempRoot + ) + ) + } + + func testWindowUnregisterSnapshotPersistencePolicy() { + XCTAssertTrue( + AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false) + ) + XCTAssertFalse( + AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true) + ) + XCTAssertTrue( + AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: false) + ) + XCTAssertFalse( + AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: true) + ) + } + + func testShouldSkipSessionSaveDuringStartupRestorePolicy() { + XCTAssertTrue( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: true, + includeScrollback: false + ) + ) + XCTAssertFalse( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: true, + includeScrollback: true + ) + ) + XCTAssertFalse( + AppDelegate.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: false, + includeScrollback: false + ) + ) + } + + func testResolvedWindowFramePrefersSavedDisplayIdentity() { + let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800), + visibleFrame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800) + ) + + // Display 1 and 2 swapped horizontal positions between snapshot and restore. + let display1 = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800) + ) + let display2 = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [display1, display2], + fallbackDisplay: display1 + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(display2.visibleFrame.intersects(restored)) + XCTAssertFalse(display1.visibleFrame.intersects(restored)) + XCTAssertEqual(restored.width, 600, accuracy: 0.001) + XCTAssertEqual(restored.height, 400, accuracy: 0.001) + XCTAssertEqual(restored.minX, 200, accuracy: 0.001) + XCTAssertEqual(restored.minY, 100, accuracy: 0.001) + } + + func testResolvedWindowFrameKeepsIntersectingFrameWithoutDisplayMetadata() { + let savedFrame = SessionRectSnapshot(x: 120, y: 80, width: 500, height: 350) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: nil, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 120, accuracy: 0.001) + XCTAssertEqual(restored.minY, 80, accuracy: 0.001) + XCTAssertEqual(restored.width, 500, accuracy: 0.001) + XCTAssertEqual(restored.height, 350, accuracy: 0.001) + } + + func testResolvedStartupPrimaryWindowFrameFallsBackToPersistedGeometryWhenPrimaryMissing() { + let fallbackFrame = SessionRectSnapshot(x: 180, y: 140, width: 900, height: 640) + let fallbackDisplay = SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000) + ) + + let restored = AppDelegate.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackFrame, + fallbackDisplaySnapshot: fallbackDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 180, accuracy: 0.001) + XCTAssertEqual(restored.minY, 140, accuracy: 0.001) + XCTAssertEqual(restored.width, 900, accuracy: 0.001) + XCTAssertEqual(restored.height, 640, accuracy: 0.001) + } + + func testResolvedStartupPrimaryWindowFramePrefersPrimarySnapshotOverFallback() { + let primarySnapshot = SessionWindowSnapshot( + frame: SessionRectSnapshot(x: 220, y: 160, width: 980, height: 700), + display: SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ), + tabManager: SessionTabManagerSnapshot(selectedWorkspaceIndex: nil, workspaces: []), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 220) + ) + let fallbackFrame = SessionRectSnapshot(x: 40, y: 30, width: 700, height: 500) + let fallbackDisplay = SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000) + ) + + let restored = AppDelegate.resolvedStartupPrimaryWindowFrame( + primarySnapshot: primarySnapshot, + fallbackFrame: fallbackFrame, + fallbackDisplaySnapshot: fallbackDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 220, accuracy: 0.001) + XCTAssertEqual(restored.minY, 160, accuracy: 0.001) + XCTAssertEqual(restored.width, 980, accuracy: 0.001) + XCTAssertEqual(restored.height, 700, accuracy: 0.001) + } + + func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() { + let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: nil, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(display.visibleFrame.contains(restored)) + XCTAssertEqual(restored.minX, 50, accuracy: 0.001) + XCTAssertEqual(restored.minY, 50, accuracy: 0.001) + XCTAssertEqual(restored.width, 900, accuracy: 0.001) + XCTAssertEqual(restored.height, 700, accuracy: 0.001) + } + + func testResolvedWindowFramePreservesExactGeometryWhenDisplayIsUnchanged() { + let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: CGRect(x: 0, y: 0, width: 2_560, height: 1_410) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 1_303, accuracy: 0.001) + XCTAssertEqual(restored.minY, -90, accuracy: 0.001) + XCTAssertEqual(restored.width, 1_280, accuracy: 0.001) + XCTAssertEqual(restored.height, 1_410, accuracy: 0.001) + } + + func testResolvedWindowFrameClampsWhenDisplayGeometryChangesEvenWithSameDisplayID() { + let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410) + ) + let resizedDisplay = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080), + visibleFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_050) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [resizedDisplay], + fallbackDisplay: resizedDisplay + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(resizedDisplay.visibleFrame.contains(restored)) + XCTAssertNotEqual(restored.minX, 1_303, "Changed display geometry should clamp/remap frame") + XCTAssertNotEqual(restored.minY, -90, "Changed display geometry should clamp/remap frame") + } + + func testResolvedSnapshotTerminalScrollbackPrefersCaptured() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: "captured-value", + fallbackScrollback: "fallback-value" + ) + + XCTAssertEqual(resolved, "captured-value") + } + + func testResolvedSnapshotTerminalScrollbackFallsBackWhenCaptureMissing() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: "fallback-value" + ) + + XCTAssertEqual(resolved, "fallback-value") + } + + func testResolvedSnapshotTerminalScrollbackTruncatesFallback() { + let oversizedFallback = String( + repeating: "x", + count: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + 37 + ) + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: oversizedFallback + ) + + XCTAssertEqual( + resolved?.count, + SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + ) + } + + private func makeSnapshot(version: Int) -> AppSessionSnapshot { + let workspace = SessionWorkspaceSnapshot( + processTitle: "Terminal", + customTitle: "Restored", + customColor: nil, + isPinned: true, + currentDirectory: "/tmp", + focusedPanelId: nil, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil + ) + + let tabManager = SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [workspace] + ) + + let window = SessionWindowSnapshot( + frame: SessionRectSnapshot(x: 10, y: 20, width: 900, height: 700), + display: SessionDisplaySnapshot( + displayID: 42, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1920, height: 1200), + visibleFrame: SessionRectSnapshot(x: 0, y: 25, width: 1920, height: 1175) + ), + tabManager: tabManager, + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240) + ) + + return AppSessionSnapshot( + version: version, + createdAt: Date().timeIntervalSince1970, + windows: [window] + ) + } +} diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 3186eb6a..63348c49 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -1,7 +1,12 @@ import XCTest import Foundation import AppKit + +#if canImport(cmux_DEV) @testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif /// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths. /// This prevents accidentally hiding the update UI in Release builds. @@ -144,6 +149,23 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase { XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil)) } + func testPreparedNavigationRequestPreservesOriginalMethodBodyAndHeaders() throws { + let url = try XCTUnwrap(URL(string: "http://localtest.me:3000/submit")) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = Data("token=abc123".utf8) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + let prepared = browserPreparedNavigationRequest(request) + + XCTAssertEqual(prepared.url, url) + XCTAssertEqual(prepared.httpMethod, "POST") + XCTAssertEqual(prepared.httpBody, Data("token=abc123".utf8)) + XCTAssertEqual(prepared.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded") + XCTAssertEqual(prepared.cachePolicy, .useProtocolCachePolicy) + } + func testOneTimeBypassIsConsumedAfterFirstNavigation() throws { let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) var bypassHostOnce: String? = "neverssl.com" diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift new file mode 100644 index 00000000..6e8d62e3 --- /dev/null +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class WorkspaceContentViewVisibilityTests: XCTestCase { + func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() { + XCTAssertFalse( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: false, + isSelectedInPane: true, + isFocused: true + ) + ) + } + + func testPanelVisibleInUIReturnsTrueForSelectedPanel() { + XCTAssertTrue( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: true, + isFocused: false + ) + ) + } + + func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() { + XCTAssertTrue( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: false, + isFocused: true + ) + ) + } + + func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() { + XCTAssertFalse( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: false, + isFocused: false + ) + ) + } +} diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift new file mode 100644 index 00000000..1610dc34 --- /dev/null +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -0,0 +1,439 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class WorkspaceManualUnreadTests: XCTestCase { + func testShouldClearManualUnreadWhenFocusMovesToDifferentPanel() { + let previousFocusedPanelId = UUID() + let nextFocusedPanelId = UUID() + + XCTAssertTrue( + Workspace.shouldClearManualUnread( + previousFocusedPanelId: previousFocusedPanelId, + nextFocusedPanelId: nextFocusedPanelId, + isManuallyUnread: true, + markedAt: Date() + ) + ) + } + + func testShouldNotClearManualUnreadWhenFocusStaysOnSamePanelWithinGrace() { + let panelId = UUID() + let now = Date() + + XCTAssertFalse( + Workspace.shouldClearManualUnread( + previousFocusedPanelId: panelId, + nextFocusedPanelId: panelId, + isManuallyUnread: true, + markedAt: now.addingTimeInterval(-0.05), + now: now, + sameTabGraceInterval: 0.2 + ) + ) + } + + func testShouldClearManualUnreadWhenFocusStaysOnSamePanelAfterGrace() { + let panelId = UUID() + let now = Date() + + XCTAssertTrue( + Workspace.shouldClearManualUnread( + previousFocusedPanelId: panelId, + nextFocusedPanelId: panelId, + isManuallyUnread: true, + markedAt: now.addingTimeInterval(-0.25), + now: now, + sameTabGraceInterval: 0.2 + ) + ) + } + + func testShouldNotClearManualUnreadWhenNotManuallyUnread() { + XCTAssertFalse( + Workspace.shouldClearManualUnread( + previousFocusedPanelId: UUID(), + nextFocusedPanelId: UUID(), + isManuallyUnread: false, + markedAt: Date() + ) + ) + } + + func testShouldNotClearManualUnreadWhenNoPreviousFocusAndWithinGrace() { + let now = Date() + + XCTAssertFalse( + Workspace.shouldClearManualUnread( + previousFocusedPanelId: nil, + nextFocusedPanelId: UUID(), + isManuallyUnread: true, + markedAt: now.addingTimeInterval(-0.05), + now: now, + sameTabGraceInterval: 0.2 + ) + ) + } + + func testShouldShowUnreadIndicatorWhenNotificationIsUnread() { + XCTAssertTrue( + Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: true, + isManuallyUnread: false + ) + ) + } + + func testShouldShowUnreadIndicatorWhenManualUnreadIsSet() { + XCTAssertTrue( + Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: false, + isManuallyUnread: true + ) + ) + } + + func testShouldHideUnreadIndicatorWhenNeitherNotificationNorManualUnreadExists() { + XCTAssertFalse( + Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: false, + isManuallyUnread: false + ) + ) + } +} + +final class CommandPaletteFuzzyMatcherTests: XCTestCase { + func testExactMatchScoresHigherThanPrefixAndContains() { + let exact = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab") + let prefix = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab now") + let contains = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "command rename tab flow") + + XCTAssertNotNil(exact) + XCTAssertNotNil(prefix) + XCTAssertNotNil(contains) + XCTAssertGreaterThan(exact ?? 0, prefix ?? 0) + XCTAssertGreaterThan(prefix ?? 0, contains ?? 0) + } + + func testInitialismMatchReturnsScore() { + let score = CommandPaletteFuzzyMatcher.score(query: "ocdi", candidate: "open current directory in ide") + XCTAssertNotNil(score) + XCTAssertGreaterThan(score ?? 0, 0) + } + + func testLongTokenLooseSubsequenceDoesNotMatch() { + let score = CommandPaletteFuzzyMatcher.score(query: "rename", candidate: "open current directory in ide") + XCTAssertNil(score) + } + + func testStitchedWordPrefixMatchesRetabForRenameTab() { + let score = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…") + XCTAssertNotNil(score) + XCTAssertGreaterThan(score ?? 0, 0) + } + + func testRetabPrefersRenameTabOverDistantTabWord() { + let renameTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…") + let reopenTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Reopen Closed Browser Tab") + + XCTAssertNotNil(renameTabScore) + XCTAssertNotNil(reopenTabScore) + XCTAssertGreaterThan(renameTabScore ?? 0, reopenTabScore ?? 0) + } + + func testRenameScoresHigherThanUnrelatedCommand() { + let renameScore = CommandPaletteFuzzyMatcher.score( + query: "rename", + candidates: ["Rename Tab…", "Tab • Terminal 1", "rename", "tab", "title"] + ) + let unrelatedScore = CommandPaletteFuzzyMatcher.score( + query: "rename", + candidates: [ + "Open Current Directory in IDE", + "Terminal • Terminal 1", + "terminal", + "directory", + "open", + "ide", + "code", + "default app" + ] + ) + + XCTAssertNotNil(renameScore) + XCTAssertNotNil(unrelatedScore) + XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore ?? 0) + } + + func testTokenMatchingRequiresAllTokens() { + let match = CommandPaletteFuzzyMatcher.score( + query: "rename workspace", + candidates: ["Rename Workspace", "Workspace settings"] + ) + let miss = CommandPaletteFuzzyMatcher.score( + query: "rename workspace", + candidates: ["Rename Tab", "Tab settings"] + ) + + XCTAssertNotNil(match) + XCTAssertNil(miss) + } + + func testEmptyQueryReturnsZeroScore() { + let score = CommandPaletteFuzzyMatcher.score(query: " ", candidate: "anything") + XCTAssertEqual(score, 0) + } + + func testMatchCharacterIndicesForContainsMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "workspace", + candidate: "New Workspace" + ) + XCTAssertTrue(indices.contains(4)) + XCTAssertTrue(indices.contains(12)) + XCTAssertFalse(indices.contains(0)) + } + + func testMatchCharacterIndicesForSubsequenceMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "nws", + candidate: "New Workspace" + ) + XCTAssertTrue(indices.contains(0)) + XCTAssertTrue(indices.contains(2)) + XCTAssertTrue(indices.contains(8)) + } + + func testMatchCharacterIndicesForStitchedWordPrefixMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "retab", + candidate: "Rename Tab…" + ) + XCTAssertTrue(indices.contains(0)) + XCTAssertTrue(indices.contains(1)) + XCTAssertTrue(indices.contains(7)) + XCTAssertTrue(indices.contains(8)) + XCTAssertTrue(indices.contains(9)) + } +} + +final class CommandPaletteSwitcherSearchIndexerTests: XCTestCase { + func testKeywordsIncludeDirectoryBranchAndPortMetadata() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"], + branches: ["feature/cmd-palette-indexing"], + ports: [3000, 9222] + ) + + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace", "switch"], + metadata: metadata + ) + + XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("3000")) + XCTAssertTrue(keywords.contains(":9222")) + } + + func testFuzzyMatcherMatchesDirectoryBranchAndPortMetadata() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/cmuxterm/worktrees/issue-123-switcher-search"], + branches: ["fix/switcher-metadata"], + ports: [4317] + ) + + let candidates = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata + ) + + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-search", candidates: candidates)) + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-metadata", candidates: candidates)) + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "4317", candidates: candidates)) + } + + func testWorkspaceDetailOmitsSplitDirectoryAndBranchTokens() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"], + branches: ["feature/cmd-palette-indexing"], + ports: [3000] + ) + + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata, + detail: .workspace + ) + + XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("3000")) + XCTAssertFalse(keywords.contains("feat-cmd-palette")) + XCTAssertFalse(keywords.contains("cmd-palette-indexing")) + } + + func testSurfaceDetailOutranksWorkspaceDetailForPathToken() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/worktrees/cmux"], + branches: ["feature/cmd-palette"], + ports: [] + ) + + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata, + detail: .workspace + ) + let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["surface"], + metadata: metadata, + detail: .surface + ) + + let workspaceScore = try XCTUnwrap( + CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: workspaceKeywords) + ) + let surfaceScore = try XCTUnwrap( + CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: surfaceKeywords) + ) + + XCTAssertGreaterThan( + surfaceScore, + workspaceScore, + "Surface rows should rank ahead of workspace rows for directory-token matches." + ) + } +} + +@MainActor +final class CommandPaletteRequestRoutingTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testRequestedWindowTargetsOnlyMatchingObservedWindow() { + let windowA = makeWindow() + let windowB = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: windowA, + requestedWindow: windowA, + keyWindow: windowA, + mainWindow: windowA + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: windowB, + requestedWindow: windowA, + keyWindow: windowA, + mainWindow: windowA + ) + ) + } + + func testNilRequestedWindowFallsBackToKeyWindow() { + let key = makeWindow() + let other = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: key, + requestedWindow: nil, + keyWindow: key, + mainWindow: nil + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: other, + requestedWindow: nil, + keyWindow: key, + mainWindow: nil + ) + ) + } + + func testNilRequestedAndKeyFallsBackToMainWindow() { + let main = makeWindow() + let other = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: main, + requestedWindow: nil, + keyWindow: nil, + mainWindow: main + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: other, + requestedWindow: nil, + keyWindow: nil, + mainWindow: main + ) + ) + } + + func testNoObservedWindowNeverHandlesRequest() { + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: nil, + requestedWindow: makeWindow(), + keyWindow: makeWindow(), + mainWindow: makeWindow() + ) + ) + } +} + +final class CommandPaletteBackNavigationTests: XCTestCase { + func testBackspaceOnEmptyRenameInputReturnsToCommandList() { + XCTAssertTrue( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [] + ) + ) + } + + func testBackspaceWithRenameTextDoesNotReturnToCommandList() { + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "Terminal 1", + modifiers: [] + ) + ) + } + + func testModifiedBackspaceDoesNotReturnToCommandList() { + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [.control] + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [.command] + ) + ) + } +} diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index 02ec9239..578b3005 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -546,6 +546,68 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } } + func testCtrlDEarlyDuringSplitStartupKeepsWindowOpen() { + let attempts = 12 + for attempt in 1...attempts { + let app = XCUIApplication() + let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-ctrl-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: dataPath) + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] = "lr" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_d" + app.launch() + app.activate() + defer { app.terminate() } + + XCTAssertTrue( + waitForAnyJSON(atPath: dataPath, timeout: 12.0), + "Attempt \(attempt): expected early Ctrl+D setup data at \(dataPath)" + ) + guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else { + XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = done["setupError"], !setupError.isEmpty { + XCTFail("Attempt \(attempt): setup failed: \(setupError)") + return + } + + let workspaceCountAfter = Int(done["workspaceCountAfter"] ?? "") ?? -1 + let panelCountAfter = Int(done["panelCountAfter"] ?? "") ?? -1 + let closedWorkspace = (done["closedWorkspace"] ?? "") == "1" + let timedOut = (done["timedOut"] ?? "") == "1" + let triggerMode = done["autoTriggerMode"] ?? "" + let exitPanelId = done["exitPanelId"] ?? "" + let workspaceId = done["workspaceId"] ?? "" + let probeSurfaceId = done["probeShowChildExitedSurfaceId"] ?? "" + let probeTabId = done["probeShowChildExitedTabId"] ?? "" + + XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+D timed out. data=\(done)") + XCTAssertEqual(triggerMode, "strict_early_ctrl_d", "Attempt \(attempt): expected strict early Ctrl+D trigger mode. data=\(done)") + XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+D. data=\(done)") + XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+D. data=\(done)") + XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+D. data=\(done)") + if let showChildExitedCount = Int(done["probeShowChildExitedCount"] ?? "") { + XCTAssertEqual(showChildExitedCount, 1, "Attempt \(attempt): expected exactly one SHOW_CHILD_EXITED callback for one early Ctrl+D. data=\(done)") + } + if !exitPanelId.isEmpty, !probeSurfaceId.isEmpty { + XCTAssertEqual(probeSurfaceId, exitPanelId, "Attempt \(attempt): SHOW_CHILD_EXITED should target the split opened by Cmd+D. data=\(done)") + } + if !workspaceId.isEmpty, !probeTabId.isEmpty { + XCTAssertEqual(probeTabId, workspaceId, "Attempt \(attempt): SHOW_CHILD_EXITED should resolve to the active workspace. data=\(done)") + } + XCTAssertTrue( + waitForWindowCount(app: app, atLeast: 1, timeout: 2.0), + "Attempt \(attempt): app window should remain open after early Ctrl+D. data=\(done)" + ) + } + } + private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { diff --git a/cmuxUITests/SidebarResizeUITests.swift b/cmuxUITests/SidebarResizeUITests.swift index 57c47214..6844cbeb 100644 --- a/cmuxUITests/SidebarResizeUITests.swift +++ b/cmuxUITests/SidebarResizeUITests.swift @@ -35,4 +35,31 @@ final class SidebarResizeUITests: XCTestCase { XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left") XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset") } + + func testSidebarResizerHasMaximumWidthCap() { + let app = XCUIApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0)) + + let elements = app.descendants(matching: .any) + let resizer = elements["SidebarResizer"] + XCTAssertTrue(resizer.waitForExistence(timeout: 5.0)) + + let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let farRight = start.withOffset(CGVector(dx: 5000, dy: 0)) + start.press(forDuration: 0.1, thenDragTo: farRight) + + let windowFrame = window.frame + let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX) + let minimumExpectedRemaining = windowFrame.width * 0.45 + + XCTAssertGreaterThanOrEqual( + remainingWidth, + minimumExpectedRemaining, + "Expected sidebar max-width clamp to leave substantial terminal width. " + + "remaining=\(remainingWidth), window=\(windowFrame.width)" + ) + } } diff --git a/cmuxUITests/UpdatePillUITests.swift b/cmuxUITests/UpdatePillUITests.swift index 88c00b53..b8abb185 100644 --- a/cmuxUITests/UpdatePillUITests.swift +++ b/cmuxUITests/UpdatePillUITests.swift @@ -1,6 +1,24 @@ import XCTest import Foundation +// UI runners can adjust wall clock time mid-test; use monotonic uptime for polling deadlines. +private func pollUntil( + timeout: TimeInterval, + pollInterval: TimeInterval = 0.05, + condition: () -> Bool +) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { + return true + } + if (ProcessInfo.processInfo.systemUptime - start) >= timeout { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } +} + final class UpdatePillUITests: XCTestCase { override func setUp() { super.setUp() @@ -131,25 +149,28 @@ final class UpdatePillUITests: XCTestCase { } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + pollUntil(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) { - let deadline = Date().addingTimeInterval(timeout) + let pollInterval: TimeInterval = 0.05 var size = element.frame.size - while Date() < deadline { + var exists = element.exists + var hittable = element.isHittable + + let visible = pollUntil(timeout: timeout, pollInterval: pollInterval) { size = element.frame.size - if size.width > 20 && size.height > 10 { - return - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + exists = element.exists + hittable = element.isHittable + return size.width > 20 && size.height > 10 + } + if !visible { + XCTFail( + "Expected UpdatePill to have visible size, got \(size), exists=\(exists), hittable=\(hittable)" + ) } - XCTFail("Expected UpdatePill to have visible size, got \(size)") } private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) { @@ -197,12 +218,14 @@ final class UpdatePillUITests: XCTestCase { private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { app.launch() - let deadline = Date().addingTimeInterval(activateTimeout) - while Date() < deadline, app.state != .runningForeground { + let activated = pollUntil(timeout: activateTimeout) { + guard app.state != .runningForeground else { + return true + } app.activate() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return app.state == .runningForeground } - if app.state != .runningForeground { + if !activated { app.activate() } } @@ -293,40 +316,32 @@ final class TitlebarShortcutHintsUITests: XCTestCase { app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"] app.launch() - let deadline = Date().addingTimeInterval(2.0) - while Date() < deadline, app.state != .runningForeground { + _ = pollUntil(timeout: 2.0) { + guard app.state != .runningForeground else { + return true + } app.activate() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return app.state == .runningForeground } return app } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + pollUntil(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + pollUntil(timeout: timeout) { if element.exists { let frame = element.frame if frame.width > 1, frame.height > 1 { return true } } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } - - if element.exists { - let frame = element.frame - return frame.width > 1 && frame.height > 1 - } - return false } } diff --git a/docs/socket-focus-steal-audit.todo.md b/docs/socket-focus-steal-audit.todo.md new file mode 100644 index 00000000..dd49450a --- /dev/null +++ b/docs/socket-focus-steal-audit.todo.md @@ -0,0 +1,76 @@ +# Socket/CLI No-Focus-Steal Todo + +## Goal +Ensure commands run through the cmux Unix socket/CLI do not steal user focus from the current UI workflow. + +Policy target: +- App activation/window raising from socket commands: **never**. +- In-app focus mutation from socket commands: only for explicit focus-intent commands. +- Non-focus commands must not move workspace/pane/surface focus as a side effect. + +## Task Checklist +- [x] Inventory all v1 + v2 socket command entrypoints. +- [x] Add socket-command focus policy context in `TerminalController`. +- [x] Suppress app activation for socket command path in `AppDelegate` (`focusMainWindow`, `createMainWindow`). +- [x] Gate in-app focus mutation side-effects in v2 handlers. +- [x] Gate in-app focus mutation side-effects in legacy v1 handlers. +- [x] Add explicit CLI `rename-tab` command with env-default targeting. +- [x] Update CLI help/usage/subcommand docs for `rename-tab`. +- [x] Add regression tests for rename-tab and no-unintended-focus-side-effects. +- [x] Run build + targeted tests. +- [x] Open PR. + +## Explicit Focus-Intent Allowlist +These may mutate in-app focus/selection state: + +v1: +- `focus_window` +- `select_workspace` +- `focus_surface` +- `focus_pane` +- `focus_surface_by_panel` +- `focus_webview` +- `focus_notification` (debug) +- `activate_app` (debug) + +v2: +- `window.focus` +- `workspace.select` +- `workspace.next` +- `workspace.previous` +- `workspace.last` +- `surface.focus` +- `pane.focus` +- `pane.last` +- `browser.focus_webview` +- `browser.focus` +- `browser.tab.switch` +- `debug.notification.focus` +- `debug.app.activate` + +All other commands should preserve current user focus context. + +## Command Coverage Matrix (All Command Families) +- [x] v1 `ping`, `help` +- [x] v1 window commands (`list_windows`, `current_window`, `focus_window`, `new_window`, `close_window`) +- [x] v1 workspace commands (`move_workspace_to_window`, `list_workspaces`, `new_workspace`, `close_workspace`, `select_workspace`, `current_workspace`) +- [x] v1 surface/pane commands (`new_split`, `list_surfaces`, `focus_surface`, `list_panes`, `list_pane_surfaces`, `focus_pane`, `focus_surface_by_panel`, `drag_surface_to_split`, `new_pane`, `new_surface`, `close_surface`, `refresh_surfaces`, `surface_health`) +- [x] v1 input commands (`send`, `send_key`, `send_surface`, `send_key_surface`, `read_screen`) +- [x] v1 notification/status/log/report commands (`notify*`, `list_notifications`, `clear_notifications`, `set_status`, `clear_status`, `list_status`, `log`, `clear_log`, `list_log`, `set_progress`, `clear_progress`, `report_*`, `ports_kick`, `sidebar_state`, `reset_sidebar`) +- [x] v1 browser commands (`open_browser`, `navigate`, `browser_back`, `browser_forward`, `browser_reload`, `get_url`, `focus_webview`, `is_webview_focused`) +- [x] v1 debug/test commands (shortcut, type, drop/pasteboard, overlay probes, focus checks, screenshots, render/layout/flash/panel snapshot) + +- [x] v2 system methods (`system.*`) +- [x] v2 window methods (`window.*`) +- [x] v2 workspace methods (`workspace.*`) +- [x] v2 surface methods (`surface.*`, `tab.action`) +- [x] v2 pane methods (`pane.*`) +- [x] v2 notification methods (`notification.*`) +- [x] v2 app methods (`app.*`) +- [x] v2 browser methods (full `browser.*` set including tab/network/trace/input) +- [x] v2 debug methods (`debug.*`) + +## CLI Coverage +- [x] Ensure every top-level CLI command routes to non-focus-stealing socket behavior. +- [x] Add/verify `rename-workspace` + `rename-window` behavior remains intact. +- [x] Add explicit `rename-tab` command (defaults to `CMUX_TAB_ID` / `CMUX_SURFACE_ID` / `CMUX_WORKSPACE_ID` when flags omitted). diff --git a/scripts/build-sign-upload.sh b/scripts/build-sign-upload.sh index 7f6d9644..06f4e8d8 100755 --- a/scripts/build-sign-upload.sh +++ b/scripts/build-sign-upload.sh @@ -2,10 +2,50 @@ set -euo pipefail # Build, sign, notarize, create DMG, generate appcast, and upload to GitHub release. -# Usage: ./scripts/build-sign-upload.sh +# Usage: ./scripts/build-sign-upload.sh [--allow-overwrite] # Requires: source ~/.secrets/cmuxterm.env && export SPARKLE_PRIVATE_KEY -TAG="${1:?Usage: $0 }" +usage() { + cat <<'EOF' +Usage: ./scripts/build-sign-upload.sh [--allow-overwrite] + +Options: + --allow-overwrite Permit replacing existing release assets for the same tag. + Use only for emergency rerolls. +EOF +} + +ALLOW_OVERWRITE="false" +POSITIONAL=() +while [[ $# -gt 0 ]]; do + case "$1" in + --allow-overwrite) + ALLOW_OVERWRITE="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + POSITIONAL+=("$1") + shift + ;; + esac +done +set -- "${POSITIONAL[@]}" + +if [[ $# -ne 1 ]]; then + usage >&2 + exit 1 +fi + +TAG="$1" SIGN_HASH="A050CC7E193C8221BDBA204E731B046CDCCC1B30" ENTITLEMENTS="cmux.entitlements" APP_PATH="build/Build/Products/Release/cmux.app" @@ -81,8 +121,29 @@ echo "Generating appcast..." # --- Create GitHub release (if needed) and upload --- if gh release view "$TAG" >/dev/null 2>&1; then - echo "Uploading to existing release $TAG..." - gh release upload "$TAG" cmux-macos.dmg appcast.xml --clobber + echo "Release $TAG already exists" + EXISTING_ASSETS="$(gh release view "$TAG" --json assets --jq '.assets[].name' || true)" + HAS_CONFLICTING_ASSET="false" + for asset in cmux-macos.dmg appcast.xml; do + if printf '%s\n' "$EXISTING_ASSETS" | grep -Fxq "$asset"; then + HAS_CONFLICTING_ASSET="true" + break + fi + done + + if [[ "$HAS_CONFLICTING_ASSET" == "true" && "$ALLOW_OVERWRITE" != "true" ]]; then + echo "ERROR: Refusing to overwrite signed release assets for existing tag $TAG." >&2 + echo "Use a new tag, or rerun with --allow-overwrite for an emergency reroll." >&2 + exit 1 + fi + + if [[ "$ALLOW_OVERWRITE" == "true" ]]; then + echo "Uploading with overwrite enabled for existing release $TAG..." + gh release upload "$TAG" cmux-macos.dmg appcast.xml --clobber + else + echo "Uploading to existing release $TAG..." + gh release upload "$TAG" cmux-macos.dmg appcast.xml + fi else echo "Creating release $TAG and uploading..." gh release create "$TAG" cmux-macos.dmg appcast.xml --title "$TAG" --notes "See CHANGELOG.md for details" diff --git a/scripts/release_asset_guard.js b/scripts/release_asset_guard.js new file mode 100644 index 00000000..d16d328e --- /dev/null +++ b/scripts/release_asset_guard.js @@ -0,0 +1,37 @@ +"use strict"; + +const IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"]; +const RELEASE_ASSET_GUARD_STATE = Object.freeze({ + CLEAR: "clear", + PARTIAL: "partial", + COMPLETE: "complete", +}); + +function evaluateReleaseAssetGuard({ existingAssetNames, immutableAssetNames = IMMUTABLE_RELEASE_ASSETS }) { + const immutableAssets = immutableAssetNames || IMMUTABLE_RELEASE_ASSETS; + const existing = new Set(existingAssetNames || []); + const conflicts = immutableAssets.filter((assetName) => existing.has(assetName)); + const missingImmutableAssets = immutableAssets.filter((assetName) => !existing.has(assetName)); + + let guardState = RELEASE_ASSET_GUARD_STATE.CLEAR; + if (conflicts.length === immutableAssets.length && immutableAssets.length > 0) { + guardState = RELEASE_ASSET_GUARD_STATE.COMPLETE; + } else if (conflicts.length > 0) { + guardState = RELEASE_ASSET_GUARD_STATE.PARTIAL; + } + + return { + conflicts, + missingImmutableAssets, + guardState, + hasPartialConflict: guardState === RELEASE_ASSET_GUARD_STATE.PARTIAL, + shouldSkipBuildAndUpload: guardState === RELEASE_ASSET_GUARD_STATE.COMPLETE, + shouldSkipUpload: guardState === RELEASE_ASSET_GUARD_STATE.COMPLETE, + }; +} + +module.exports = { + IMMUTABLE_RELEASE_ASSETS, + RELEASE_ASSET_GUARD_STATE, + evaluateReleaseAssetGuard, +}; diff --git a/scripts/release_asset_guard.test.js b/scripts/release_asset_guard.test.js new file mode 100644 index 00000000..c320cf81 --- /dev/null +++ b/scripts/release_asset_guard.test.js @@ -0,0 +1,49 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + IMMUTABLE_RELEASE_ASSETS, + RELEASE_ASSET_GUARD_STATE, + evaluateReleaseAssetGuard, +} = require("./release_asset_guard"); + +test("marks guard as complete and skips build/upload when all immutable assets already exist", () => { + const result = evaluateReleaseAssetGuard({ + existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"], + }); + + assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS); + assert.deepEqual(result.missingImmutableAssets, []); + assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.COMPLETE); + assert.equal(result.hasPartialConflict, false); + assert.equal(result.shouldSkipBuildAndUpload, true); + assert.equal(result.shouldSkipUpload, true); +}); + +test("marks guard as clear when immutable assets are not present", () => { + const result = evaluateReleaseAssetGuard({ + existingAssetNames: ["notes.txt", "checksums.txt"], + }); + + assert.deepEqual(result.conflicts, []); + assert.deepEqual(result.missingImmutableAssets, IMMUTABLE_RELEASE_ASSETS); + assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.CLEAR); + assert.equal(result.hasPartialConflict, false); + assert.equal(result.shouldSkipBuildAndUpload, false); + assert.equal(result.shouldSkipUpload, false); +}); + +test("marks guard as partial when only some immutable assets exist", () => { + const result = evaluateReleaseAssetGuard({ + existingAssetNames: ["appcast.xml"], + }); + + assert.deepEqual(result.conflicts, ["appcast.xml"]); + assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]); + assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL); + assert.equal(result.hasPartialConflict, true); + assert.equal(result.shouldSkipBuildAndUpload, false); + assert.equal(result.shouldSkipUpload, false); +}); diff --git a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh index ac08502d..7798e8e7 100755 --- a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh +++ b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh @@ -93,6 +93,7 @@ sidebarBlurOpacity="$(format_number "$(read_value sidebarBlurOpacity 0.79)" 2)" sidebarTintHex="$(read_value sidebarTintHex '#101010')" sidebarTintOpacity="$(format_number "$(read_value sidebarTintOpacity 0.54)" 2)" sidebarCornerRadius="$(format_number "$(read_value sidebarCornerRadius 0.0)" 1)" +sidebarActiveTabIndicatorStyle="$(read_value sidebarActiveTabIndicatorStyle solidFill)" shortcutHintSidebarXOffset="$(format_number "$(read_value shortcutHintSidebarXOffset 0.0)" 1)" shortcutHintSidebarYOffset="$(format_number "$(read_value shortcutHintSidebarYOffset 0.0)" 1)" shortcutHintTitlebarXOffset="$(format_number "$(read_value shortcutHintTitlebarXOffset 4.0)" 1)" @@ -141,6 +142,7 @@ sidebarBlurOpacity=$sidebarBlurOpacity sidebarTintHex=$sidebarTintHex sidebarTintOpacity=$sidebarTintOpacity sidebarCornerRadius=$sidebarCornerRadius +sidebarActiveTabIndicatorStyle=$sidebarActiveTabIndicatorStyle shortcutHintSidebarXOffset=$shortcutHintSidebarXOffset shortcutHintSidebarYOffset=$shortcutHintSidebarYOffset shortcutHintTitlebarXOffset=$shortcutHintTitlebarXOffset diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 19e8fa6d..9144e22b 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -1,6 +1,6 @@ --- name: release -description: Prepare and ship a cmux release end-to-end: choose the next version, curate user-facing changelog entries, bump versions, open and monitor a release PR, merge, tag, and verify published artifacts. Use when asked to cut, prepare, publish, or tag a new release. +description: "Prepare and ship a cmux release end-to-end: choose the next version, curate user-facing changelog entries, bump versions, open and monitor a release PR, merge, tag, and verify published artifacts. Use when asked to cut, prepare, publish, or tag a new release." --- # Release @@ -16,15 +16,18 @@ Run this workflow to prepare and publish a cmux release. 2. Create a release branch: - `git checkout -b release/vX.Y.Z` -3. Gather user-facing changes since the last tag: +3. Gather user-facing changes and contributors since the last tag: - `git describe --tags --abbrev=0` - `git log --oneline ..HEAD --no-merges` - Keep only end-user visible changes (features, bug fixes, UX/perf behavior). +- **Collect contributors:** For each PR, get the author with `gh pr view --repo manaflow-ai/cmux --json author --jq '.author.login'`. Also check linked issue reporters with `gh issue view --json author --jq '.author.login'`. +- Build a deduplicated list of all contributor `@handle`s. 4. Update changelogs: - Update `CHANGELOG.md`. - Update `docs-site/content/docs/changelog.mdx`. - Use categories `Added`, `Changed`, `Fixed`, `Removed`. +- **Credit contributors inline** (see Contributor Credits below). - If no user-facing changes exist, confirm with the user before continuing. 5. Bump app version metadata: @@ -64,3 +67,10 @@ Run this workflow to prepare and publish a cmux release. - Exclude internal-only changes (CI, tests, docs-only edits, refactors without behavior changes). - Write concise user-facing bullets in present tense. +## Contributor Credits + +Credit the people who made each release happen: + +- **Per-entry:** Append `— thanks @user!` for community code contributions. Use `— thanks @user for the report!` for bug reporters (when different from PR author). No callout for core team (`lawrencecchen`, `austinywang`) — core work is the baseline. +- **Summary:** Add a `### Thanks to N contributors!` section at the bottom of each release with an alphabetical list of all `[@handle](https://github.com/handle)` links (including core team). +- **GitHub Release body:** Include the same "Thanks to N contributors!" section with linked handles. diff --git a/tests/test_browser_chrome_contrast_regression.py b/tests/test_browser_chrome_contrast_regression.py new file mode 100644 index 00000000..a2552f2f --- /dev/null +++ b/tests/test_browser_chrome_contrast_regression.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Static regression guards for browser chrome contrast in mixed theme setups.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + + raise ValueError(f"Unbalanced braces for: {signature}") + + +def main() -> int: + root = repo_root() + view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" + source = view_path.read_text(encoding="utf-8") + failures: list[str] = [] + + try: + browser_panel_view_block = extract_block(source, "struct BrowserPanelView: View") + except ValueError as error: + failures.append(str(error)) + browser_panel_view_block = "" + + try: + resolver_block = extract_block(source, "func resolvedBrowserChromeColorScheme(") + except ValueError as error: + failures.append(str(error)) + resolver_block = "" + + if resolver_block: + if "backgroundColor.isLightColor ? .light : .dark" not in resolver_block: + failures.append( + "resolvedBrowserChromeColorScheme must map luminance to a light/dark ColorScheme" + ) + + try: + chrome_scheme_block = extract_block( + browser_panel_view_block, + "private var browserChromeColorScheme: ColorScheme", + ) + except ValueError as error: + failures.append(str(error)) + chrome_scheme_block = "" + + if chrome_scheme_block and "resolvedBrowserChromeColorScheme(" not in chrome_scheme_block: + failures.append("browserChromeColorScheme must use resolvedBrowserChromeColorScheme") + + try: + omnibar_background_block = extract_block( + browser_panel_view_block, + "private var omnibarPillBackgroundColor: NSColor", + ) + except ValueError as error: + failures.append(str(error)) + omnibar_background_block = "" + + if omnibar_background_block and "for: browserChromeColorScheme" not in omnibar_background_block: + failures.append("omnibar pill background must use browserChromeColorScheme") + + try: + address_bar_block = extract_block( + browser_panel_view_block, + "private var addressBar: some View", + ) + except ValueError as error: + failures.append(str(error)) + address_bar_block = "" + + if address_bar_block and ".environment(\\.colorScheme, browserChromeColorScheme)" not in address_bar_block: + failures.append("addressBar must apply browserChromeColorScheme via environment") + + try: + body_block = extract_block(browser_panel_view_block, "var body: some View") + except ValueError as error: + failures.append(str(error)) + body_block = "" + + if body_block: + if "OmnibarSuggestionsView(" not in body_block: + failures.append("Expected OmnibarSuggestionsView block in BrowserPanelView body") + elif ".environment(\\.colorScheme, browserChromeColorScheme)" not in body_block: + failures.append("Omnibar suggestions must apply browserChromeColorScheme via environment") + + if failures: + print("FAIL: browser chrome contrast regression guards failed") + for failure in failures: + print(f" - {failure}") + return 1 + + print("PASS: browser chrome contrast regression guards are in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_browser_console_errors_cli_output_regression.py b/tests/test_browser_console_errors_cli_output_regression.py new file mode 100644 index 00000000..40561356 --- /dev/null +++ b/tests/test_browser_console_errors_cli_output_regression.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Static regression guard for browser console/errors CLI output formatting. + +Ensures non-JSON `browser console list` and `browser errors list` do not fall +back to unconditional `OK` when logs exist. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + raise ValueError(f"Unbalanced braces for: {signature}") + + +def main() -> int: + root = repo_root() + failures: list[str] = [] + + cli_path = root / "CLI" / "cmux.swift" + cli_source = cli_path.read_text(encoding="utf-8") + browser_block = extract_block(cli_source, "private func runBrowserCommand(") + + if "func displayBrowserLogItems(_ value: Any?) -> String?" not in browser_block: + failures.append("runBrowserCommand() is missing displayBrowserLogItems() helper") + else: + helper_block = extract_block(browser_block, "func displayBrowserLogItems(_ value: Any?) -> String?") + if "return \"[\\(level)] \\(text)\"" not in helper_block: + failures.append("displayBrowserLogItems() no longer renders level-prefixed log lines") + if "return \"[error] \\(message)\"" not in helper_block: + failures.append("displayBrowserLogItems() no longer renders concise JS error messages") + if "return displayBrowserValue(dict)" not in helper_block: + failures.append("displayBrowserLogItems() no longer falls back to structured formatting") + + console_block = extract_block(browser_block, 'if subcommand == "console"') + if 'displayBrowserLogItems(payload["entries"])' not in console_block: + failures.append("browser console path no longer formats entries for non-JSON output") + if 'output(payload, fallback: "OK")' in console_block: + failures.append("browser console path regressed to unconditional OK output") + + errors_block = extract_block(browser_block, 'if subcommand == "errors"') + if 'displayBrowserLogItems(payload["errors"])' not in errors_block: + failures.append("browser errors path no longer formats errors for non-JSON output") + if 'output(payload, fallback: "OK")' in errors_block: + failures.append("browser errors path regressed to unconditional OK output") + + if failures: + print("FAIL: browser console/errors CLI output regression guard failed") + for item in failures: + print(f" - {item}") + return 1 + + print("PASS: browser console/errors CLI output regression guard is in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_browser_eval_cli_output_regression.py b/tests/test_browser_eval_cli_output_regression.py new file mode 100644 index 00000000..b8778a52 --- /dev/null +++ b/tests/test_browser_eval_cli_output_regression.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Static regression guard for browser eval CLI output formatting. + +Ensures `cmux browser eval