Merge origin/main into issue-151-ssh-remote-port-proxying
This commit is contained in:
commit
c179ee74ea
202 changed files with 54781 additions and 2314 deletions
|
|
@ -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 <last-tag>..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 <N> --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 <N> --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.
|
||||
|
|
|
|||
|
|
@ -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 <last-tag>..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 <N> --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 <N> --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.
|
||||
|
|
|
|||
|
|
@ -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 <last-tag>..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 <N> --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 <N> --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)
|
||||
```
|
||||
|
|
|
|||
97
.github/workflows/build-ghosttykit.yml
vendored
Normal file
97
.github/workflows/build-ghosttykit.yml
vendored
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
name: Build GhosttyKit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-ghosttykit:
|
||||
# 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
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Get ghostty SHA
|
||||
id: ghostty-sha
|
||||
run: |
|
||||
SHA=$(git -C ghostty rev-parse HEAD)
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "Ghostty SHA: $SHA"
|
||||
|
||||
- name: Check if xcframework release already exists
|
||||
id: check-release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="xcframework-${{ steps.ghostty-sha.outputs.sha }}"
|
||||
if gh release view "$TAG" --repo manaflow-ai/ghostty >/dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Release $TAG already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Release $TAG not found, will build"
|
||||
fi
|
||||
|
||||
- name: Select Xcode
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
||||
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
|
||||
else
|
||||
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$XCODE_APP" ]; then
|
||||
XCODE_DIR="$XCODE_APP/Contents/Developer"
|
||||
else
|
||||
echo "No Xcode.app found under /Applications" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
|
||||
export DEVELOPER_DIR="$XCODE_DIR"
|
||||
xcodebuild -version
|
||||
|
||||
- name: Build GhosttyKit.xcframework
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
brew install zig
|
||||
else
|
||||
echo "zig is required to build GhosttyKit.xcframework. Install zig and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast
|
||||
|
||||
- name: Package xcframework
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf GhosttyKit.xcframework
|
||||
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
|
||||
tar czf GhosttyKit.xcframework.tar.gz GhosttyKit.xcframework
|
||||
|
||||
- name: Upload xcframework release
|
||||
if: steps.check-release.outputs.exists == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHOSTTY_RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="xcframework-${{ steps.ghostty-sha.outputs.sha }}"
|
||||
gh release create "$TAG" \
|
||||
--repo manaflow-ai/ghostty \
|
||||
--title "GhosttyKit xcframework (${{ steps.ghostty-sha.outputs.sha }})" \
|
||||
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }}" \
|
||||
GhosttyKit.xcframework.tar.gz
|
||||
echo "Published release $TAG"
|
||||
99
.github/workflows/ci.yml
vendored
99
.github/workflows/ci.yml
vendored
|
|
@ -7,6 +7,24 @@ on:
|
|||
pull_request:
|
||||
|
||||
jobs:
|
||||
workflow-guard-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Validate self-hosted runner guards
|
||||
run: ./tests/test_ci_self_hosted_guard.sh
|
||||
|
||||
- name: Validate create-dmg version pinning
|
||||
run: ./tests/test_ci_create_dmg_pinned.sh
|
||||
|
||||
- name: Validate unit-test SwiftPM retry guard
|
||||
run: ./tests/test_ci_unit_test_spm_retry.sh
|
||||
|
||||
- name: Validate cmux scheme test configuration
|
||||
run: ./tests/test_ci_scheme_testaction_debug.sh
|
||||
|
||||
web-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
|
@ -14,10 +32,10 @@ jobs:
|
|||
working-directory: web
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
|
@ -25,14 +43,16 @@ jobs:
|
|||
- name: Typecheck
|
||||
run: bun tsc --noEmit
|
||||
|
||||
ui-tests:
|
||||
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
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
|
@ -79,8 +99,75 @@ jobs:
|
|||
# Remove stale build cache to avoid incremental build errors
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
|
||||
- name: Resolve Swift packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
rm -rf "$SOURCE_PACKAGES_DIR"
|
||||
mkdir -p "$SOURCE_PACKAGES_DIR"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-resolvePackageDependencies; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Failed to resolve Swift packages after 3 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Package resolution failed on attempt $attempt, retrying..."
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
run_unit_tests() {
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=macOS" test 2>&1
|
||||
}
|
||||
|
||||
# xcodebuild exits 65 even for expected failures (XCTExpectFailure).
|
||||
# Capture output and fail only if there are unexpected failures.
|
||||
set +e
|
||||
OUTPUT=$(run_unit_tests)
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
# SwiftPM binary artifact resolution can occasionally fail on self-hosted
|
||||
# runners with "Could not resolve package dependencies". Retry once after
|
||||
# clearing SwiftPM/DerivedData caches to recover from transient corruption.
|
||||
if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then
|
||||
echo "SwiftPM package resolution failed, clearing caches and retrying once"
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
set +e
|
||||
OUTPUT=$(run_unit_tests)
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
fi
|
||||
|
||||
echo "$OUTPUT"
|
||||
if [ "$EXIT_CODE" -ne 0 ]; then
|
||||
SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1)
|
||||
if echo "$SUMMARY" | grep -q "(0 unexpected)"; then
|
||||
echo "All failures are expected, treating as pass"
|
||||
else
|
||||
echo "Unexpected test failures detected"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Run UI tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Run directly on the self-hosted macOS runner.
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=macOS" \
|
||||
-only-testing:cmuxUITests/UpdatePillUITests test
|
||||
|
|
|
|||
38
.github/workflows/nightly.yml
vendored
38
.github/workflows/nightly.yml
vendored
|
|
@ -15,6 +15,9 @@ on:
|
|||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CREATE_DMG_VERSION: 8.0.0
|
||||
|
||||
jobs:
|
||||
decide:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -25,7 +28,7 @@ jobs:
|
|||
steps:
|
||||
- name: Decide whether a nightly build is needed
|
||||
id: decide
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
env:
|
||||
FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }}
|
||||
with:
|
||||
|
|
@ -84,7 +87,7 @@ jobs:
|
|||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head_sha }}
|
||||
submodules: recursive
|
||||
|
|
@ -112,7 +115,7 @@ jobs:
|
|||
run: |
|
||||
brew update
|
||||
brew install zig
|
||||
npm install --global create-dmg
|
||||
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
|
||||
|
||||
- name: Build GhosttyKit.xcframework
|
||||
run: |
|
||||
|
|
@ -190,6 +193,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 +207,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 +293,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 +318,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: |
|
||||
|
|
@ -302,7 +329,7 @@ jobs:
|
|||
git push origin refs/tags/nightly --force
|
||||
|
||||
- name: Publish nightly release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
tag_name: nightly
|
||||
name: Nightly
|
||||
|
|
@ -315,6 +342,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
|
||||
|
|
|
|||
93
.github/workflows/release.yml
vendored
93
.github/workflows/release.yml
vendored
|
|
@ -9,6 +9,9 @@ on:
|
|||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CREATE_DMG_VERSION: 8.0.0
|
||||
|
||||
jobs:
|
||||
build-sign-notarize:
|
||||
runs-on: self-hosted
|
||||
|
|
@ -17,11 +20,67 @@ jobs:
|
|||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Guard immutable release assets
|
||||
id: guard_release_assets
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # 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 +100,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
|
||||
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
|
||||
|
||||
- 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 +120,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 +135,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 +148,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 +167,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 +191,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 +209,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 +253,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 +279,14 @@ jobs:
|
|||
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml
|
||||
|
||||
- name: Upload release asset
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: steps.guard_release_assets.outputs.skip_upload != 'true'
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
files: |
|
||||
cmux-macos.dmg
|
||||
appcast.xml
|
||||
generate_release_notes: true
|
||||
overwrite_files: false
|
||||
|
||||
- name: Cleanup keychain
|
||||
if: always()
|
||||
|
|
|
|||
2
.github/workflows/update-homebrew.yml
vendored
2
.github/workflows/update-homebrew.yml
vendored
|
|
@ -65,7 +65,7 @@ jobs:
|
|||
echo "DMG SHA256: $SHA256"
|
||||
|
||||
- name: Checkout homebrew-cmux
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: manaflow-ai/homebrew-cmux
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,6 +38,7 @@ zig-out/
|
|||
|
||||
# Node
|
||||
node_modules/
|
||||
.next/
|
||||
|
||||
# Test outputs
|
||||
tests/visual_output/
|
||||
|
|
|
|||
0
.gitkeep
Normal file
0
.gitkeep
Normal file
95
CHANGELOG.md
95
CHANGELOG.md
|
|
@ -2,6 +2,101 @@
|
|||
|
||||
All notable changes to cmux are documented here.
|
||||
|
||||
## [0.61.0] - 2026-02-25
|
||||
|
||||
### Added
|
||||
- Command palette (Cmd+Shift+P) with update actions and all-window switcher results ([#358](https://github.com/manaflow-ai/cmux/pull/358), [#361](https://github.com/manaflow-ai/cmux/pull/361))
|
||||
- Split actions and shortcut hints in terminal context menus
|
||||
- Cross-window tab and workspace move UI with improved destination focus behavior
|
||||
- Sidebar pull request metadata rows and workspace PR open actions
|
||||
- Workspace color schemes and left-rail workspace indicator settings ([#324](https://github.com/manaflow-ai/cmux/pull/324), [#329](https://github.com/manaflow-ai/cmux/pull/329), [#332](https://github.com/manaflow-ai/cmux/pull/332))
|
||||
- URL open-wrapper routing into the embedded browser ([#332](https://github.com/manaflow-ai/cmux/pull/332))
|
||||
- Cmd+Q quit warning with suppression toggle ([#295](https://github.com/manaflow-ai/cmux/pull/295))
|
||||
- `cmux --version` output now includes commit metadata
|
||||
|
||||
### Changed
|
||||
- Added light mode and unified theme refresh across app surfaces ([#258](https://github.com/manaflow-ai/cmux/pull/258)) — thanks @ijpatricio for the report!
|
||||
- Browser link middle-click handling now uses native WebKit behavior ([#416](https://github.com/manaflow-ai/cmux/pull/416))
|
||||
- Settings-window actions now route through a single command-palette/settings flow
|
||||
- Sentry upgraded with tracing, breadcrumbs, and dSYM upload support ([#366](https://github.com/manaflow-ai/cmux/pull/366))
|
||||
- Session restore scope clarification: cmux restores layout, working directory, scrollback, and browser history, but does not resume live terminal process state yet
|
||||
|
||||
### Fixed
|
||||
- Startup split hang when pressing Cmd+D then Ctrl+D early after launch ([#364](https://github.com/manaflow-ai/cmux/pull/364))
|
||||
- Browser focus handoff and click-to-focus regressions in mixed terminal/browser workspaces ([#381](https://github.com/manaflow-ai/cmux/pull/381), [#355](https://github.com/manaflow-ai/cmux/pull/355))
|
||||
- Caps Lock handling in browser omnibar keyboard paths ([#382](https://github.com/manaflow-ai/cmux/pull/382))
|
||||
- Embedded browser deeplink URL scheme handling ([#392](https://github.com/manaflow-ai/cmux/pull/392))
|
||||
- Sidebar resize cap regression ([#393](https://github.com/manaflow-ai/cmux/pull/393))
|
||||
- Terminal zoom inheritance for new splits, surfaces, and workspaces ([#384](https://github.com/manaflow-ai/cmux/pull/384))
|
||||
- Terminal find overlay layering across split and portal-hosted layouts
|
||||
- Titlebar drag and double-click zoom handling on browser-side panes
|
||||
- Stale browser favicon and window-title updates after navigation
|
||||
|
||||
### Thanks to 7 contributors!
|
||||
- [@austinywang](https://github.com/austinywang)
|
||||
- [@avisser](https://github.com/avisser)
|
||||
- [@gnguralnick](https://github.com/gnguralnick)
|
||||
- [@ijpatricio](https://github.com/ijpatricio)
|
||||
- [@jperkin](https://github.com/jperkin)
|
||||
- [@jungcome7](https://github.com/jungcome7)
|
||||
- [@lawrencecchen](https://github.com/lawrencecchen)
|
||||
|
||||
## [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
|
||||
|
|
|
|||
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -95,8 +95,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 <submodule> && 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`:
|
||||
|
|
@ -150,7 +167,7 @@ git commit -m "Update ghostty submodule"
|
|||
Use the `/release` command to prepare a new release. This will:
|
||||
1. Determine the new version (bumps minor by default)
|
||||
2. Gather commits since the last tag and update the changelog
|
||||
3. Update `CHANGELOG.md` and `docs-site/content/docs/changelog.mdx`
|
||||
3. Update `CHANGELOG.md` (the docs changelog page at `web/app/docs/changelog/page.tsx` reads from it)
|
||||
4. Run `./scripts/bump-version.sh` to update both versions
|
||||
5. Commit, tag, and push
|
||||
|
||||
|
|
@ -179,4 +196,4 @@ Notes:
|
|||
- The release asset is `cmux-macos.dmg` attached to the tag.
|
||||
- README download button points to `releases/latest/download/cmux-macos.dmg`.
|
||||
- Versioning: bump the minor version for updates unless explicitly asked otherwise.
|
||||
- Changelog: always update both `CHANGELOG.md` and the docs-site version.
|
||||
- Changelog: update `CHANGELOG.md`; docs changelog is rendered from it.
|
||||
|
|
|
|||
2112
CLI/cmux.swift
2112
CLI/cmux.swift
File diff suppressed because it is too large
Load diff
|
|
@ -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 */; };
|
||||
|
|
@ -34,10 +35,12 @@
|
|||
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; };
|
||||
A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; };
|
||||
A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; };
|
||||
B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; };
|
||||
A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; };
|
||||
A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; };
|
||||
A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; };
|
||||
A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; };
|
||||
A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; };
|
||||
A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; };
|
||||
A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; };
|
||||
A5001202 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001212 /* UpdateDelegate.swift */; };
|
||||
|
|
@ -54,11 +57,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 +76,15 @@
|
|||
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 */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
A5001020 /* Embed Frameworks */ = {
|
||||
|
|
@ -96,6 +105,7 @@
|
|||
files = (
|
||||
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */,
|
||||
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */,
|
||||
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */,
|
||||
);
|
||||
name = "Copy CLI";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -144,6 +154,7 @@
|
|||
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
||||
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
||||
A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; };
|
||||
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; };
|
||||
A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; };
|
||||
A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -162,6 +173,7 @@
|
|||
A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; };
|
||||
A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
|
||||
A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = "<group>"; };
|
||||
A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; };
|
||||
A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = "<group>"; };
|
||||
A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = "<group>"; };
|
||||
A5001213 /* UpdateDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDriver.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -177,11 +189,13 @@
|
|||
A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = "<group>"; };
|
||||
A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = "<group>"; };
|
||||
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
|
||||
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
|
||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
|
||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
|
||||
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
|
||||
C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; };
|
||||
D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/open"; sourceTree = SOURCE_ROOT; };
|
||||
A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; };
|
||||
B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = "<group>"; };
|
||||
B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
|
@ -190,14 +204,18 @@
|
|||
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = "<group>"; };
|
||||
B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = "<group>"; };
|
||||
B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A5001030 /* Frameworks */ = {
|
||||
|
|
@ -229,6 +247,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -307,6 +326,7 @@
|
|||
B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */,
|
||||
A50012F0 /* Backport.swift */,
|
||||
A50012F2 /* KeyboardShortcutSettings.swift */,
|
||||
A50012F4 /* KeyboardLayout.swift */,
|
||||
A5001013 /* TabManager.swift */,
|
||||
A5001511 /* UITestRecorder.swift */,
|
||||
A5001520 /* PostHogAnalytics.swift */,
|
||||
|
|
@ -319,6 +339,7 @@
|
|||
A5001019 /* TerminalController.swift */,
|
||||
A5001541 /* PortScanner.swift */,
|
||||
A5001225 /* SocketControlSettings.swift */,
|
||||
A5001600 /* SentryHelper.swift */,
|
||||
A5001090 /* AppDelegate.swift */,
|
||||
A5001091 /* NotificationsPage.swift */,
|
||||
A5001092 /* TerminalNotificationStore.swift */,
|
||||
|
|
@ -345,6 +366,7 @@
|
|||
A5001219 /* WindowToolbarController.swift */,
|
||||
A5001241 /* WindowDecorationsController.swift */,
|
||||
A5001222 /* WindowAccessor.swift */,
|
||||
A5001611 /* SessionPersistence.swift */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -395,17 +417,21 @@
|
|||
path = cmuxUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -447,6 +473,9 @@
|
|||
);
|
||||
dependencies = (
|
||||
);
|
||||
packageProductDependencies = (
|
||||
A5001251 /* Sentry */,
|
||||
);
|
||||
name = "cmux-cli";
|
||||
productName = cmux;
|
||||
productReference = B9000004A1B2C3D4E5F60719 /* cmux */;
|
||||
|
|
@ -536,6 +565,7 @@
|
|||
B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */,
|
||||
A50012F1 /* Backport.swift in Sources */,
|
||||
A50012F3 /* KeyboardShortcutSettings.swift in Sources */,
|
||||
A50012F5 /* KeyboardLayout.swift in Sources */,
|
||||
A5001003 /* TabManager.swift in Sources */,
|
||||
A5001501 /* UITestRecorder.swift in Sources */,
|
||||
A5001521 /* PostHogAnalytics.swift in Sources */,
|
||||
|
|
@ -548,6 +578,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 +605,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 +626,22 @@
|
|||
);
|
||||
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 */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9000006A1B2C3D4E5F60719 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -702,7 +738,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -711,7 +747,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -741,7 +777,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -750,7 +786,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -778,6 +814,12 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_NAME = cmux;
|
||||
PRODUCT_MODULE_NAME = cmux_cli;
|
||||
|
|
@ -791,6 +833,12 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_NAME = cmux;
|
||||
PRODUCT_MODULE_NAME = cmux_cli;
|
||||
|
|
@ -804,10 +852,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -821,10 +869,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -838,10 +886,10 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -857,10 +905,10 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="1500" version="1.7">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</TestableReference>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<MacroExpansion>
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</MacroExpansion>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="YES" debugDocumentVersioning="YES" allowLocationSimulation="YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction buildConfiguration="Debug" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction buildConfiguration="Debug"/>
|
||||
<ArchiveAction buildConfiguration="Debug" revealArchiveInOrganizer="YES"/>
|
||||
</Scheme>
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Release" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
|
|
|
|||
66
README.md
66
README.md
|
|
@ -11,12 +11,17 @@
|
|||
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux screenshot" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a>
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
|
@ -52,7 +57,7 @@ Split a browser alongside your terminal with a scriptable API ported from <a hre
|
|||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Vertical + horizontal tabs</h3>
|
||||
Sidebar shows git branch, working directory, listening ports, and latest notification text. Split horizontally and vertically.
|
||||
Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertical tabs and split panes" width="100%" />
|
||||
|
|
@ -96,12 +101,22 @@ I run a lot of Claude Code and Codex sessions in parallel. I was using Ghostty w
|
|||
|
||||
I tried a few coding orchestrators but most of them were Electron/Tauri apps and the performance bugged me. I also just prefer the terminal since GUI orchestrators lock you into their workflow. So I built cmux as a native macOS app in Swift/AppKit. It uses libghostty for terminal rendering and reads your existing Ghostty config for themes, fonts, and colors.
|
||||
|
||||
The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
|
||||
The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, linked PR status/number, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
|
||||
|
||||
The in-app browser has a scriptable API ported from [agent-browser](https://github.com/vercel-labs/agent-browser). Agents can snapshot the accessibility tree, get element refs, click, fill forms, and evaluate JS. You can split a browser pane next to your terminal and have Claude Code interact with your dev server directly.
|
||||
|
||||
Everything is scriptable through the CLI and socket API — create workspaces/tabs, split panes, send keystrokes, open URLs in the browser.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux is not prescriptive about how developers hold their tools. It's a terminal and browser with a CLI, and the rest is up to you.
|
||||
|
||||
cmux is a primitive, not a solution. It gives you a terminal, a browser, notifications, workspaces, splits, tabs, and a CLI to control all of it. cmux doesn't force you into an opinionated way to use coding agents. What you build with the primitives is yours.
|
||||
|
||||
The best developers have always built their own tools. Nobody has figured out the best way to work with agents yet, and the teams building closed products definitely haven't either. The developers closest to their own codebases will figure it out first.
|
||||
|
||||
Give a million developers composable primitives and they'll collectively find the most efficient workflows faster than any product team could design top-down.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Workspaces
|
||||
|
|
@ -114,6 +129,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,14 +209,56 @@ 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.
|
||||
|
||||
## Session restore (current behavior)
|
||||
|
||||
On relaunch, cmux currently restores app layout and metadata only:
|
||||
- Window/workspace/pane layout
|
||||
- Working directories
|
||||
- Terminal scrollback (best effort)
|
||||
- Browser URL and navigation history
|
||||
|
||||
cmux does **not** restore live process state inside terminal apps. For example, active Claude Code/tmux/vim sessions are not resumed after restart yet.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contributing
|
||||
|
||||
Ways to get involved:
|
||||
|
||||
- Follow us on X for updates [@manaflowai](https://x.com/manaflowai) or [@lawrencecchen](https://x.com/lawrencecchen)
|
||||
- Join the conversation on [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- Create and participate in [GitHub issues](https://github.com/manaflow-ai/cmux/issues) and [discussions](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Let me know what you're building with cmux
|
||||
|
||||
## 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)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmux is free, open source, and always will be. If you'd like to support development and get early access to what's coming next:
|
||||
|
||||
**[Get Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Prioritized feature requests/bug fixes**
|
||||
- **Early access: cmux AI that gives you context on every workspace, tab and panel**
|
||||
- **Early access: iOS app with terminals synced between desktop and phone**
|
||||
- **Early access: Cloud VMs**
|
||||
- **Early access: Voice mode**
|
||||
- **My personal iMessage/WhatsApp**
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0 or later (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
<string></string>
|
||||
<key>NSMainStoryboardFile</key>
|
||||
<string></string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>A program running within cmux would like to use your microphone.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSServices</key>
|
||||
|
|
|
|||
283
Resources/bin/open
Executable file
283
Resources/bin/open
Executable file
|
|
@ -0,0 +1,283 @@
|
|||
#!/usr/bin/env bash
|
||||
# cmux open wrapper - routes HTTP(S) URLs to cmux's in-app browser
|
||||
#
|
||||
# When running inside a cmux terminal (CMUX_SOCKET_PATH is set), this wrapper
|
||||
# intercepts `open https://...` invocations and opens them in cmux's built-in
|
||||
# browser within the same workspace. All other arguments pass through to
|
||||
# /usr/bin/open unchanged.
|
||||
|
||||
SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}"
|
||||
DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}"
|
||||
PYTHON3_BIN="${CMUX_OPEN_WRAPPER_PYTHON3:-}"
|
||||
|
||||
if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then
|
||||
SYSTEM_OPEN_BIN="/usr/bin/open"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$DEFAULTS_BIN" ]]; then
|
||||
DEFAULTS_BIN="/usr/bin/defaults"
|
||||
fi
|
||||
|
||||
if [[ -n "$PYTHON3_BIN" ]]; then
|
||||
if [[ ! -x "$PYTHON3_BIN" ]]; then
|
||||
PYTHON3_BIN=""
|
||||
fi
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON3_BIN="$(command -v python3)"
|
||||
fi
|
||||
|
||||
settings_domain="${CMUX_BUNDLE_ID:-}"
|
||||
whitelist_raw=""
|
||||
whitelist_patterns=()
|
||||
|
||||
system_open() {
|
||||
exec "$SYSTEM_OPEN_BIN" "$@"
|
||||
}
|
||||
|
||||
trim() {
|
||||
local value="$1"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
to_lower_ascii() {
|
||||
# Bash 3.2-compatible lowercase conversion.
|
||||
LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
normalize_boolean() {
|
||||
to_lower_ascii "$(trim "$1")"
|
||||
}
|
||||
|
||||
is_false_setting() {
|
||||
local normalized
|
||||
normalized="$(normalize_boolean "$1")"
|
||||
case "$normalized" in
|
||||
0|false|no|off)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
canonicalize_idn_host() {
|
||||
local value="$1"
|
||||
[[ -z "$PYTHON3_BIN" ]] && {
|
||||
printf '%s' "$value"
|
||||
return 0
|
||||
}
|
||||
|
||||
local canonicalized
|
||||
canonicalized="$("$PYTHON3_BIN" - "$value" <<'PY' 2>/dev/null || true
|
||||
import sys
|
||||
|
||||
host = sys.argv[1].strip().rstrip(".")
|
||||
if not host:
|
||||
raise SystemExit(1)
|
||||
|
||||
labels = host.split(".")
|
||||
if any(not label for label in labels):
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
canonical = ".".join(label.encode("idna").decode("ascii") for label in labels)
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
|
||||
sys.stdout.write(canonical.lower())
|
||||
PY
|
||||
)"
|
||||
if [[ -n "$canonicalized" ]]; then
|
||||
printf '%s' "$canonicalized"
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
is_http_url() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
[Hh][Tt][Tt][Pp]://*|[Hh][Tt][Tt][Pp][Ss]://*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
normalize_host() {
|
||||
local value
|
||||
value="$(trim "$1")"
|
||||
value="$(to_lower_ascii "$value")"
|
||||
[[ -z "$value" ]] && return 1
|
||||
|
||||
if [[ "$value" == *"://"* ]]; then
|
||||
value="${value#*://}"
|
||||
fi
|
||||
|
||||
value="${value%%/*}"
|
||||
value="${value%%\?*}"
|
||||
value="${value%%\#*}"
|
||||
|
||||
if [[ "$value" == *"@"* ]]; then
|
||||
value="${value##*@}"
|
||||
fi
|
||||
|
||||
if [[ "$value" == \[* ]]; then
|
||||
value="${value#\[}"
|
||||
value="${value%%\]*}"
|
||||
elif [[ "$value" == *:* ]]; then
|
||||
local colons="${value//[^:]}"
|
||||
if [[ ${#colons} -eq 1 ]] && [[ "$value" =~ :[0-9]+$ ]]; then
|
||||
value="${value%:*}"
|
||||
fi
|
||||
fi
|
||||
|
||||
while [[ "$value" == .* ]]; do
|
||||
value="${value#.}"
|
||||
done
|
||||
while [[ "$value" == *. ]]; do
|
||||
value="${value%.}"
|
||||
done
|
||||
|
||||
[[ -z "$value" ]] && return 1
|
||||
value="$(canonicalize_idn_host "$value")"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
normalize_whitelist_pattern() {
|
||||
local value
|
||||
value="$(trim "$1")"
|
||||
value="$(to_lower_ascii "$value")"
|
||||
[[ -z "$value" ]] && return 1
|
||||
|
||||
if [[ "$value" == \*.* ]]; then
|
||||
local suffix
|
||||
suffix="$(normalize_host "${value#*.}")" || return 1
|
||||
printf '*.%s' "$suffix"
|
||||
return 0
|
||||
fi
|
||||
|
||||
normalize_host "$value"
|
||||
}
|
||||
|
||||
host_matches_pattern() {
|
||||
local host="$1"
|
||||
local pattern="$2"
|
||||
|
||||
if [[ "$pattern" == \*.* ]]; then
|
||||
local suffix="${pattern#*.}"
|
||||
[[ "$host" == "$suffix" ]] && return 0
|
||||
[[ "$host" == *".$suffix" ]] && return 0
|
||||
return 1
|
||||
fi
|
||||
|
||||
[[ "$host" == "$pattern" ]]
|
||||
}
|
||||
|
||||
host_matches_whitelist() {
|
||||
local url="$1"
|
||||
if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local host
|
||||
host="$(normalize_host "$url")" || return 1
|
||||
for pattern in "${whitelist_patterns[@]}"; do
|
||||
if host_matches_pattern "$host" "$pattern"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
load_whitelist_patterns() {
|
||||
local raw="$1"
|
||||
local line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
local normalized
|
||||
normalized="$(normalize_whitelist_pattern "$line")" || continue
|
||||
whitelist_patterns+=("$normalized")
|
||||
done <<< "$raw"
|
||||
}
|
||||
|
||||
# Pass through immediately if not in a cmux terminal.
|
||||
if [[ -z "$CMUX_SOCKET_PATH" ]]; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
# No arguments → pass through.
|
||||
if [[ $# -eq 0 ]]; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
# Scan for flags that indicate explicit user intent → pass through.
|
||||
# Also collect non-flag arguments (potential URLs/files).
|
||||
passthrough=false
|
||||
urls=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr)
|
||||
passthrough=true
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
# Unknown flag → be conservative, pass through
|
||||
passthrough=true
|
||||
break
|
||||
;;
|
||||
*)
|
||||
if is_http_url "$arg"; then
|
||||
urls+=("$arg")
|
||||
else
|
||||
# Non-URL, non-flag argument (file path, etc.) → pass through all
|
||||
passthrough=true
|
||||
break
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
# Respect the same settings used for terminal link clicks.
|
||||
if [[ -n "$settings_domain" ]]; then
|
||||
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserInterceptTerminalOpenCommandInCmuxBrowser 2>/dev/null || true)"
|
||||
if [[ -z "$open_in_cmux" ]]; then
|
||||
# Backward compatibility for installs that predate the dedicated open-wrapper toggle.
|
||||
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)"
|
||||
fi
|
||||
if is_false_setting "$open_in_cmux"; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)"
|
||||
if [[ -n "$whitelist_raw" ]]; then
|
||||
load_whitelist_patterns "$whitelist_raw"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Find cmux CLI (same directory as this script).
|
||||
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CMUX_CLI="$SELF_DIR/cmux"
|
||||
|
||||
if [[ ! -x "$CMUX_CLI" ]]; then
|
||||
system_open "$@"
|
||||
fi
|
||||
|
||||
# Open each URL in cmux's in-app browser; track failures individually.
|
||||
failed_urls=()
|
||||
for url in "${urls[@]}"; do
|
||||
if ! host_matches_whitelist "$url"; then
|
||||
failed_urls+=("$url")
|
||||
continue
|
||||
fi
|
||||
"$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
|
||||
done
|
||||
|
||||
# Fall back to system open only for URLs that failed.
|
||||
if [[ ${#failed_urls[@]} -gt 0 ]]; then
|
||||
system_open "${failed_urls[@]}"
|
||||
fi
|
||||
|
|
@ -3,9 +3,9 @@
|
|||
_cmux_send() {
|
||||
local payload="$1"
|
||||
if command -v ncat >/dev/null 2>&1; then
|
||||
printf '%s\n' "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only
|
||||
printf '%s\n' "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only
|
||||
elif command -v socat >/dev/null 2>&1; then
|
||||
printf '%s\n' "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
|
||||
printf '%s\n' "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
|
||||
elif command -v nc >/dev/null 2>&1; then
|
||||
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
|
||||
#
|
||||
|
|
@ -23,11 +23,29 @@ _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:-}"
|
||||
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
|
||||
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
|
||||
_CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}"
|
||||
_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
|
||||
_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
|
||||
_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
|
||||
_CMUX_PR_JOB_STARTED_AT="${_CMUX_PR_JOB_STARTED_AT:-0}"
|
||||
_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
|
||||
|
||||
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
||||
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
|
||||
|
|
@ -67,6 +85,28 @@ _cmux_prompt_command() {
|
|||
local now=$SECONDS
|
||||
local pwd="$PWD"
|
||||
|
||||
# Post-wake socket writes can occasionally leave a probe process wedged.
|
||||
# If one probe is stale, clear the guard so fresh async probes can resume.
|
||||
if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then
|
||||
if ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_GIT_JOB_PID=""
|
||||
_CMUX_GIT_JOB_STARTED_AT=0
|
||||
elif (( _CMUX_GIT_JOB_STARTED_AT > 0 )) && (( now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
|
||||
_CMUX_GIT_JOB_PID=""
|
||||
_CMUX_GIT_JOB_STARTED_AT=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]]; then
|
||||
if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_PR_JOB_PID=""
|
||||
_CMUX_PR_JOB_STARTED_AT=0
|
||||
elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
|
||||
_CMUX_PR_JOB_PID=""
|
||||
_CMUX_PR_JOB_STARTED_AT=0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve TTY name once.
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
|
|
@ -94,6 +134,7 @@ _cmux_prompt_command() {
|
|||
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
|
||||
kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_GIT_JOB_PID=""
|
||||
_CMUX_GIT_JOB_STARTED_AT=0
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -107,12 +148,57 @@ _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=$!
|
||||
_CMUX_GIT_JOB_STARTED_AT=$now
|
||||
fi
|
||||
|
||||
# Pull request metadata (number/state/url):
|
||||
# refresh on cwd change and periodically to avoid stale status.
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
|
||||
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_PR_JOB_PID=""
|
||||
_CMUX_PR_JOB_STARTED_AT=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
|
||||
if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_PR_LAST_PWD="$pwd"
|
||||
_CMUX_PR_LAST_RUN=$now
|
||||
{
|
||||
local branch pr_tsv number state url status_opt=""
|
||||
branch=$(git branch --show-current 2>/dev/null)
|
||||
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
|
||||
if [[ -z "$pr_tsv" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
IFS=$'\t' read -r number state url <<< "$pr_tsv"
|
||||
if [[ -z "$number" || -z "$url" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
case "$state" in
|
||||
MERGED) status_opt="--state=merged" ;;
|
||||
OPEN) status_opt="--state=open" ;;
|
||||
CLOSED) status_opt="--state=closed" ;;
|
||||
*) status_opt="" ;;
|
||||
esac
|
||||
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_PR_JOB_PID=$!
|
||||
_CMUX_PR_JOB_STARTED_AT=$now
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ports: lightweight kick to the app's batched scanner every ~10s.
|
||||
|
|
@ -150,15 +236,17 @@ _cmux_install_prompt_command() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Ensure Resources/bin is at the front of PATH. Shell init (.bashrc/.bash_profile)
|
||||
# may prepend other dirs that push our wrapper behind the system claude binary.
|
||||
# Ensure Resources/bin is at the front of PATH, and remove the app's
|
||||
# Contents/MacOS entry so the GUI cmux binary cannot shadow the CLI cmux.
|
||||
# Shell init (.bashrc/.bash_profile) may prepend other dirs after launch.
|
||||
_cmux_fix_path() {
|
||||
if [[ -n "${GHOSTTY_BIN_DIR:-}" ]]; then
|
||||
local bin_dir="${GHOSTTY_BIN_DIR%/MacOS}"
|
||||
bin_dir="${bin_dir}/Resources/bin"
|
||||
local gui_dir="${GHOSTTY_BIN_DIR%/}"
|
||||
local bin_dir="${gui_dir%/MacOS}/Resources/bin"
|
||||
if [[ -d "$bin_dir" ]]; then
|
||||
local new_path=":${PATH}:"
|
||||
new_path="${new_path//:${bin_dir}:/:}"
|
||||
new_path="${new_path//:${gui_dir}:/:}"
|
||||
new_path="${new_path#:}"
|
||||
new_path="${new_path%:}"
|
||||
PATH="${bin_dir}:${new_path}"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
_cmux_send() {
|
||||
local payload="$1"
|
||||
if command -v ncat >/dev/null 2>&1; then
|
||||
print -r -- "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only
|
||||
print -r -- "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only
|
||||
elif command -v socat >/dev/null 2>&1; then
|
||||
print -r -- "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
|
||||
print -r -- "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
|
||||
elif command -v nc >/dev/null 2>&1; then
|
||||
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
|
||||
#
|
||||
|
|
@ -24,16 +24,35 @@ _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=""
|
||||
typeset -g _CMUX_GIT_LAST_RUN=0
|
||||
typeset -g _CMUX_GIT_JOB_PID=""
|
||||
typeset -g _CMUX_GIT_JOB_STARTED_AT=0
|
||||
typeset -g _CMUX_GIT_FORCE=0
|
||||
typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
|
||||
typeset -g _CMUX_GIT_HEAD_PATH=""
|
||||
typeset -g _CMUX_GIT_HEAD_MTIME=0
|
||||
typeset -g _CMUX_HAVE_ZSTAT=0
|
||||
typeset -g _CMUX_PR_LAST_PWD=""
|
||||
typeset -g _CMUX_PR_LAST_RUN=0
|
||||
typeset -g _CMUX_PR_JOB_PID=""
|
||||
typeset -g _CMUX_PR_JOB_STARTED_AT=0
|
||||
typeset -g _CMUX_PR_FORCE=0
|
||||
typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
|
||||
|
||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||
typeset -g _CMUX_CMD_START=0
|
||||
|
|
@ -143,7 +162,8 @@ _cmux_preexec() {
|
|||
local cmd="${1## }"
|
||||
case "$cmd" in
|
||||
git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *)
|
||||
_CMUX_GIT_FORCE=1 ;;
|
||||
_CMUX_GIT_FORCE=1
|
||||
_CMUX_PR_FORCE=1 ;;
|
||||
esac
|
||||
|
||||
# Register TTY + kick batched port scan for foreground commands (servers).
|
||||
|
|
@ -171,6 +191,30 @@ _cmux_precmd() {
|
|||
local cmd_start="$_CMUX_CMD_START"
|
||||
_CMUX_CMD_START=0
|
||||
|
||||
# Post-wake socket writes can occasionally leave a probe process wedged.
|
||||
# If one probe is stale, clear the guard so fresh async probes can resume.
|
||||
if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then
|
||||
if ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_GIT_JOB_PID=""
|
||||
_CMUX_GIT_JOB_STARTED_AT=0
|
||||
elif (( _CMUX_GIT_JOB_STARTED_AT > 0 )) && (( now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
|
||||
_CMUX_GIT_JOB_PID=""
|
||||
_CMUX_GIT_JOB_STARTED_AT=0
|
||||
_CMUX_GIT_FORCE=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]]; then
|
||||
if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_PR_JOB_PID=""
|
||||
_CMUX_PR_JOB_STARTED_AT=0
|
||||
elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
|
||||
_CMUX_PR_JOB_PID=""
|
||||
_CMUX_PR_JOB_STARTED_AT=0
|
||||
_CMUX_PR_FORCE=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# CWD: keep the app in sync with the actual shell directory.
|
||||
# This is also the simplest way to test sidebar directory behavior end-to-end.
|
||||
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
|
||||
|
|
@ -200,6 +244,7 @@ _cmux_precmd() {
|
|||
# Treat HEAD file change like a git command — force-replace any
|
||||
# running probe so the sidebar picks up the new branch immediately.
|
||||
_CMUX_GIT_FORCE=1
|
||||
_CMUX_PR_FORCE=1
|
||||
should_git=1
|
||||
fi
|
||||
fi
|
||||
|
|
@ -224,6 +269,7 @@ _cmux_precmd() {
|
|||
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]] || (( _CMUX_GIT_FORCE )); then
|
||||
kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_GIT_JOB_PID=""
|
||||
_CMUX_GIT_JOB_STARTED_AT=0
|
||||
else
|
||||
can_launch_git=0
|
||||
fi
|
||||
|
|
@ -240,12 +286,72 @@ _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=$!
|
||||
_CMUX_GIT_JOB_STARTED_AT=$now
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pull request metadata (number/state/url):
|
||||
# - refresh on cwd change, explicit git/gh commands, and occasionally for status drift
|
||||
# - keep this independent from the git probe cadence to avoid hitting GitHub too often
|
||||
local should_pr=0
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
|
||||
should_pr=1
|
||||
elif (( _CMUX_PR_FORCE )); then
|
||||
should_pr=1
|
||||
elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then
|
||||
should_pr=1
|
||||
fi
|
||||
|
||||
if (( should_pr )); then
|
||||
local can_launch_pr=1
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then
|
||||
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_PR_JOB_PID=""
|
||||
_CMUX_PR_JOB_STARTED_AT=0
|
||||
else
|
||||
can_launch_pr=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( can_launch_pr )); then
|
||||
_CMUX_PR_FORCE=0
|
||||
_CMUX_PR_LAST_PWD="$pwd"
|
||||
_CMUX_PR_LAST_RUN=$now
|
||||
{
|
||||
local branch pr_tsv number state url status_opt=""
|
||||
branch=$(git branch --show-current 2>/dev/null)
|
||||
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
|
||||
if [[ -z "$pr_tsv" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
local IFS=$'\t'
|
||||
read -r number state url <<< "$pr_tsv"
|
||||
if [[ -z "$number" ]] || [[ -z "$url" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
case "$state" in
|
||||
MERGED) status_opt="--state=merged" ;;
|
||||
OPEN) status_opt="--state=open" ;;
|
||||
CLOSED) status_opt="--state=closed" ;;
|
||||
*) status_opt="" ;;
|
||||
esac
|
||||
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
} >/dev/null 2>&1 &!
|
||||
_CMUX_PR_JOB_PID=$!
|
||||
_CMUX_PR_JOB_STARTED_AT=$now
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -262,17 +368,19 @@ _cmux_precmd() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Ensure Resources/bin is at the front of PATH. Shell init (.zprofile/.zshrc)
|
||||
# may prepend other dirs that push our wrapper behind the system claude binary.
|
||||
# Ensure Resources/bin is at the front of PATH, and remove the app's
|
||||
# Contents/MacOS entry so the GUI cmux binary cannot shadow the CLI cmux.
|
||||
# Shell init (.zprofile/.zshrc) may prepend other dirs after launch.
|
||||
# We fix this once on first prompt (after all init files have run).
|
||||
_cmux_fix_path() {
|
||||
if [[ -n "${GHOSTTY_BIN_DIR:-}" ]]; then
|
||||
local bin_dir="${GHOSTTY_BIN_DIR%/MacOS}"
|
||||
bin_dir="${bin_dir}/Resources/bin"
|
||||
local gui_dir="${GHOSTTY_BIN_DIR%/}"
|
||||
local bin_dir="${gui_dir%/MacOS}/Resources/bin"
|
||||
if [[ -d "$bin_dir" ]]; then
|
||||
# Remove existing entry and re-prepend.
|
||||
# Remove existing entries and re-prepend the CLI bin dir.
|
||||
local -a parts=("${(@s/:/)PATH}")
|
||||
parts=("${(@)parts:#$bin_dir}")
|
||||
parts=("${(@)parts:#$gui_dir}")
|
||||
PATH="${bin_dir}:${(j/:/)parts}"
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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..<dividerCount {
|
||||
let first = splitView.arrangedSubviews[dividerIndex].frame
|
||||
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
|
||||
let thickness = splitView.dividerThickness
|
||||
let dividerRect: NSRect
|
||||
if splitView.isVertical {
|
||||
guard first.width > 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)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ import Foundation
|
|||
import AppKit
|
||||
|
||||
struct GhosttyConfig {
|
||||
enum ColorSchemePreference {
|
||||
enum ColorSchemePreference: Hashable {
|
||||
case light
|
||||
case dark
|
||||
}
|
||||
|
||||
private static let loadCacheLock = NSLock()
|
||||
private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:]
|
||||
|
||||
var fontFamily: String = "Menlo"
|
||||
var fontSize: CGFloat = 12
|
||||
var theme: String?
|
||||
|
|
@ -45,7 +48,45 @@ struct GhosttyConfig {
|
|||
return backgroundColor.darken(by: isLightBackground ? 0.08 : 0.4)
|
||||
}
|
||||
|
||||
static func load() -> GhosttyConfig {
|
||||
static func load(
|
||||
preferredColorScheme: ColorSchemePreference? = nil,
|
||||
useCache: Bool = true,
|
||||
loadFromDisk: (_ preferredColorScheme: ColorSchemePreference) -> GhosttyConfig = Self.loadFromDisk
|
||||
) -> GhosttyConfig {
|
||||
let resolvedColorScheme = preferredColorScheme ?? currentColorSchemePreference()
|
||||
if useCache, let cached = cachedLoad(for: resolvedColorScheme) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let loaded = loadFromDisk(resolvedColorScheme)
|
||||
if useCache {
|
||||
storeCachedLoad(loaded, for: resolvedColorScheme)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
static func invalidateLoadCache() {
|
||||
loadCacheLock.lock()
|
||||
cachedConfigsByColorScheme.removeAll()
|
||||
loadCacheLock.unlock()
|
||||
}
|
||||
|
||||
private static func cachedLoad(for colorScheme: ColorSchemePreference) -> GhosttyConfig? {
|
||||
loadCacheLock.lock()
|
||||
defer { loadCacheLock.unlock() }
|
||||
return cachedConfigsByColorScheme[colorScheme]
|
||||
}
|
||||
|
||||
private static func storeCachedLoad(
|
||||
_ config: GhosttyConfig,
|
||||
for colorScheme: ColorSchemePreference
|
||||
) {
|
||||
loadCacheLock.lock()
|
||||
cachedConfigsByColorScheme[colorScheme] = config
|
||||
loadCacheLock.unlock()
|
||||
}
|
||||
|
||||
private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig {
|
||||
var config = GhosttyConfig()
|
||||
|
||||
// Match Ghostty's default load order on macOS.
|
||||
|
|
@ -64,7 +105,12 @@ struct GhosttyConfig {
|
|||
|
||||
// Load theme if specified
|
||||
if let themeName = config.theme {
|
||||
config.loadTheme(themeName)
|
||||
config.loadTheme(
|
||||
themeName,
|
||||
environment: ProcessInfo.processInfo.environment,
|
||||
bundleResourceURL: Bundle.main.resourceURL,
|
||||
preferredColorScheme: preferredColorScheme
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
46
Sources/KeyboardLayout.swift
Normal file
46
Sources/KeyboardLayout.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import Carbon
|
||||
|
||||
class KeyboardLayout {
|
||||
/// Return a string ID of the current keyboard input source.
|
||||
static var id: String? {
|
||||
if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
|
||||
let sourceIdPointer = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) {
|
||||
let sourceId = Unmanaged<CFString>.fromOpaque(sourceIdPointer).takeUnretainedValue()
|
||||
return sourceId as String
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Translate a physical keyCode to the unmodified character under the current keyboard layout.
|
||||
static func character(forKeyCode keyCode: UInt16) -> String? {
|
||||
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
|
||||
let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self)
|
||||
guard let bytes = CFDataGetBytePtr(layoutData) else { return nil }
|
||||
let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
|
||||
|
||||
var deadKeyState: UInt32 = 0
|
||||
var chars = [UniChar](repeating: 0, count: 4)
|
||||
var length = 0
|
||||
|
||||
let status = UCKeyTranslate(
|
||||
keyboardLayout,
|
||||
keyCode,
|
||||
UInt16(kUCKeyActionDisplay),
|
||||
0,
|
||||
UInt32(LMGetKbdType()),
|
||||
UInt32(kUCKeyTranslateNoDeadKeysBit),
|
||||
&deadKeyState,
|
||||
chars.count,
|
||||
&length,
|
||||
&chars
|
||||
)
|
||||
|
||||
guard status == noErr, length > 0 else { return nil }
|
||||
return String(utf16CodeUnits: chars, count: length).lowercased()
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ enum KeyboardShortcutSettings {
|
|||
case toggleSidebar
|
||||
case newTab
|
||||
case newWindow
|
||||
case closeWindow
|
||||
case openFolder
|
||||
case showNotifications
|
||||
case jumpToUnread
|
||||
case triggerFlash
|
||||
|
|
@ -17,6 +19,9 @@ enum KeyboardShortcutSettings {
|
|||
case prevSurface
|
||||
case nextSidebarTab
|
||||
case prevSidebarTab
|
||||
case renameTab
|
||||
case renameWorkspace
|
||||
case closeWorkspace
|
||||
case newSurface
|
||||
|
||||
// Panes / splits
|
||||
|
|
@ -26,6 +31,7 @@ enum KeyboardShortcutSettings {
|
|||
case focusDown
|
||||
case splitRight
|
||||
case splitDown
|
||||
case toggleSplitZoom
|
||||
case splitBrowserRight
|
||||
case splitBrowserDown
|
||||
|
||||
|
|
@ -41,6 +47,8 @@ enum KeyboardShortcutSettings {
|
|||
case .toggleSidebar: return "Toggle Sidebar"
|
||||
case .newTab: return "New Workspace"
|
||||
case .newWindow: return "New Window"
|
||||
case .closeWindow: return "Close Window"
|
||||
case .openFolder: return "Open Folder"
|
||||
case .showNotifications: return "Show Notifications"
|
||||
case .jumpToUnread: return "Jump to Latest Unread"
|
||||
case .triggerFlash: return "Flash Focused Panel"
|
||||
|
|
@ -48,6 +56,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"
|
||||
|
|
@ -55,6 +66,7 @@ enum KeyboardShortcutSettings {
|
|||
case .focusDown: return "Focus Pane Down"
|
||||
case .splitRight: return "Split Right"
|
||||
case .splitDown: return "Split Down"
|
||||
case .toggleSplitZoom: return "Toggle Pane Zoom"
|
||||
case .splitBrowserRight: return "Split Browser Right"
|
||||
case .splitBrowserDown: return "Split Browser Down"
|
||||
case .openBrowser: return "Open Browser"
|
||||
|
|
@ -68,17 +80,23 @@ enum KeyboardShortcutSettings {
|
|||
case .toggleSidebar: return "shortcut.toggleSidebar"
|
||||
case .newTab: return "shortcut.newTab"
|
||||
case .newWindow: return "shortcut.newWindow"
|
||||
case .closeWindow: return "shortcut.closeWindow"
|
||||
case .openFolder: return "shortcut.openFolder"
|
||||
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"
|
||||
case .focusDown: return "shortcut.focusDown"
|
||||
case .splitRight: return "shortcut.splitRight"
|
||||
case .splitDown: return "shortcut.splitDown"
|
||||
case .toggleSplitZoom: return "shortcut.toggleSplitZoom"
|
||||
case .splitBrowserRight: return "shortcut.splitBrowserRight"
|
||||
case .splitBrowserDown: return "shortcut.splitBrowserDown"
|
||||
case .nextSurface: return "shortcut.nextSurface"
|
||||
|
|
@ -98,6 +116,10 @@ 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 .openFolder:
|
||||
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
|
||||
case .showNotifications:
|
||||
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
|
||||
case .jumpToUnread:
|
||||
|
|
@ -108,6 +130,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:
|
||||
|
|
@ -120,6 +148,8 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
|
||||
case .splitDown:
|
||||
return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
|
||||
case .toggleSplitZoom:
|
||||
return StoredShortcut(key: "\r", command: true, shift: true, option: false, control: false)
|
||||
case .splitBrowserRight:
|
||||
return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false)
|
||||
case .splitBrowserDown:
|
||||
|
|
@ -190,6 +220,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) }
|
||||
|
|
@ -198,6 +230,7 @@ enum KeyboardShortcutSettings {
|
|||
|
||||
static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) }
|
||||
static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) }
|
||||
static func toggleSplitZoomShortcut() -> StoredShortcut { shortcut(for: .toggleSplitZoom) }
|
||||
static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) }
|
||||
static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) }
|
||||
|
||||
|
|
@ -228,6 +261,8 @@ struct StoredShortcut: Codable, Equatable {
|
|||
switch key {
|
||||
case "\t":
|
||||
keyText = "TAB"
|
||||
case "\r":
|
||||
keyText = "↩"
|
||||
default:
|
||||
keyText = key.uppercased()
|
||||
}
|
||||
|
|
@ -244,6 +279,69 @@ 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
|
||||
case "\r":
|
||||
return KeyEquivalent(Character("\r"))
|
||||
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"
|
||||
case "\r":
|
||||
return "\r"
|
||||
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 }
|
||||
|
||||
|
|
@ -274,6 +372,7 @@ struct StoredShortcut: Codable, Equatable {
|
|||
case 125: return "↓" // down arrow
|
||||
case 126: return "↑" // up arrow
|
||||
case 48: return "\t" // tab
|
||||
case 36, 76: return "\r" // return, keypad enter
|
||||
case 33: return "[" // kVK_ANSI_LeftBracket
|
||||
case 30: return "]" // kVK_ANSI_RightBracket
|
||||
case 27: return "-" // kVK_ANSI_Minus
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,87 @@ 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 {
|
||||
func cmuxFlatSymbolColorRendering() -> some View {
|
||||
// `symbolColorRenderingMode(.flat)` is not available in the current SDK
|
||||
// used by CI/local builds. Keep this modifier as a compatibility no-op.
|
||||
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 +210,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<Void, Never>?
|
||||
@State private var isLoadingRemoteSuggestions: Bool = false
|
||||
@State private var latestRemoteSuggestionQuery: String = ""
|
||||
|
|
@ -144,12 +227,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 +271,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 +325,9 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
)
|
||||
.frame(width: omnibarPillFrame.width)
|
||||
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6)
|
||||
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3)
|
||||
.zIndex(1000)
|
||||
.environment(\.colorScheme, browserChromeColorScheme)
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "BrowserPanelViewSpace")
|
||||
|
|
@ -226,25 +339,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 +382,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 +404,7 @@ struct BrowserPanelView: View {
|
|||
hideSuggestions()
|
||||
addressBarFocused = false
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
|
||||
}
|
||||
.onChange(of: addressBarFocused) { focused in
|
||||
let urlString = panel.preferredURLStringForOmnibar() ?? ""
|
||||
|
|
@ -301,6 +432,7 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
inlineCompletion = nil
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
|
||||
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
|
||||
|
|
@ -332,13 +464,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 +490,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 +506,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 +529,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 +551,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 +689,11 @@ struct BrowserPanelView: View {
|
|||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
||||
.fill(Color(nsColor: .textBackgroundColor))
|
||||
.fill(Color(nsColor: omnibarPillBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
||||
.stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1)
|
||||
.stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.accessibilityElement(children: .contain)
|
||||
.background {
|
||||
|
|
@ -502,42 +708,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 +787,32 @@ struct BrowserPanelView: View {
|
|||
applyOmnibarEffects(effects)
|
||||
}
|
||||
|
||||
private func isCommandPaletteVisibleForPanelWindow() -> Bool {
|
||||
guard let app = AppDelegate.shared else { return false }
|
||||
|
||||
if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let manager = app.tabManagerFor(tabId: panel.workspaceId),
|
||||
let windowId = app.windowId(for: manager),
|
||||
let window = app.mainWindow(for: windowId),
|
||||
app.isCommandPaletteVisible(for: window) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
|
||||
return true
|
||||
}
|
||||
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func applyPendingAddressBarFocusRequestIfNeeded() {
|
||||
guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else { return }
|
||||
guard lastHandledAddressBarFocusRequestId != requestId else { return }
|
||||
lastHandledAddressBarFocusRequestId = requestId
|
||||
panel.beginSuppressWebViewFocusForAddressBar()
|
||||
|
|
@ -575,6 +840,7 @@ struct BrowserPanelView: View {
|
|||
private func autoFocusOmnibarIfBlank() {
|
||||
guard isFocused else { return }
|
||||
guard !addressBarFocused else { return }
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else { return }
|
||||
// If a test/automation explicitly focused WebKit, don't steal focus back.
|
||||
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
|
||||
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
|
||||
|
|
@ -592,6 +858,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 +2215,13 @@ struct OmnibarSuggestion: Identifiable, Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
func browserOmnibarShouldReacquireFocusAfterEndEditing(
|
||||
suppressWebViewFocus: Bool,
|
||||
nextResponderIsOtherTextField: Bool
|
||||
) -> Bool {
|
||||
suppressWebViewFocus && !nextResponderIsOtherTextField
|
||||
}
|
||||
|
||||
private final class OmnibarNativeTextField: NSTextField {
|
||||
var onPointerDown: (() -> Void)?
|
||||
var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)?
|
||||
|
|
@ -2054,6 +2334,29 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool {
|
||||
guard let window, let field = parentField else { return false }
|
||||
let responder = window.firstResponder
|
||||
|
||||
if let editor = responder as? NSTextView,
|
||||
let delegateField = editor.delegate as? NSTextField {
|
||||
return delegateField !== field
|
||||
}
|
||||
|
||||
if let textField = responder as? NSTextField {
|
||||
return textField !== field
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
|
||||
return browserOmnibarShouldReacquireFocusAfterEndEditing(
|
||||
suppressWebViewFocus: parent.shouldSuppressWebViewFocus(),
|
||||
nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
|
||||
)
|
||||
}
|
||||
|
||||
func controlTextDidBeginEditing(_ obj: Notification) {
|
||||
if !parent.isFocused {
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -2066,15 +2369,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
|
||||
func controlTextDidEndEditing(_ obj: Notification) {
|
||||
if parent.isFocused {
|
||||
if parent.shouldSuppressWebViewFocus() {
|
||||
if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
|
||||
guard pendingFocusRequest != true else { return }
|
||||
pendingFocusRequest = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingFocusRequest = nil
|
||||
guard self.parent.isFocused else { return }
|
||||
guard self.parent.shouldSuppressWebViewFocus() else { return }
|
||||
guard let field = self.parentField, let window = field.window else { return }
|
||||
guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
|
||||
self.parent.onFieldLostFocus()
|
||||
return
|
||||
}
|
||||
// Check both the field itself AND its field editor (which becomes
|
||||
// the actual first responder when the text field is being edited).
|
||||
let fr = window.firstResponder
|
||||
|
|
@ -2387,11 +2693,12 @@ private struct OmnibarSuggestionsView: View {
|
|||
let searchSuggestionsEnabled: Bool
|
||||
let onCommit: (OmnibarSuggestion) -> Void
|
||||
let onHighlight: (Int) -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
// Keep radii below the smallest rendered heights so corners don't get
|
||||
// auto-clamped and visually change as popup height changes.
|
||||
private let popupCornerRadius: CGFloat = 16
|
||||
private let rowHighlightCornerRadius: CGFloat = 12
|
||||
// Keep radii below half of the smallest rendered heights so this keeps a
|
||||
// squircle silhouette instead of auto-clamping into a capsule.
|
||||
private let popupCornerRadius: CGFloat = 12
|
||||
private let rowHighlightCornerRadius: CGFloat = 9
|
||||
private let singleLineRowHeight: CGFloat = 24
|
||||
private let rowSpacing: CGFloat = 1
|
||||
private let topInset: CGFloat = 3
|
||||
|
|
@ -2444,6 +2751,101 @@ private struct OmnibarSuggestionsView: View {
|
|||
contentHeight > maxPopupHeight
|
||||
}
|
||||
|
||||
private var listTextColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color(nsColor: .labelColor)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.9)
|
||||
@unknown default:
|
||||
return Color(nsColor: .labelColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeTextColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.72)
|
||||
@unknown default:
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeBackgroundColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.06)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.08)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
private var rowHighlightColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.07)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.12)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.07)
|
||||
}
|
||||
}
|
||||
|
||||
private var popupOverlayGradientColors: [Color] {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return [
|
||||
Color.white.opacity(0.55),
|
||||
Color.white.opacity(0.2),
|
||||
]
|
||||
case .dark:
|
||||
return [
|
||||
Color.black.opacity(0.26),
|
||||
Color.black.opacity(0.14),
|
||||
]
|
||||
@unknown default:
|
||||
return [
|
||||
Color.white.opacity(0.55),
|
||||
Color.white.opacity(0.2),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private var popupBorderGradientColors: [Color] {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return [
|
||||
Color.white.opacity(0.65),
|
||||
Color.black.opacity(0.12),
|
||||
]
|
||||
case .dark:
|
||||
return [
|
||||
Color.white.opacity(0.22),
|
||||
Color.white.opacity(0.06),
|
||||
]
|
||||
@unknown default:
|
||||
return [
|
||||
Color.white.opacity(0.65),
|
||||
Color.black.opacity(0.12),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private var popupShadowColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.18)
|
||||
case .dark:
|
||||
return Color.black.opacity(0.45)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.18)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rowsView: some View {
|
||||
VStack(spacing: rowSpacing) {
|
||||
|
|
@ -2457,18 +2859,18 @@ private struct OmnibarSuggestionsView: View {
|
|||
HStack(spacing: 6) {
|
||||
Text(item.listText)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color.white.opacity(0.9))
|
||||
.foregroundStyle(listTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if let badge = item.trailingBadgeText {
|
||||
Text(badge)
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(Color.white.opacity(0.72))
|
||||
.foregroundStyle(badgeTextColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.fill(badgeBackgroundColor)
|
||||
)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
|
@ -2484,7 +2886,7 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
|
||||
.fill(
|
||||
idx == selectedIndex
|
||||
? Color.white.opacity(0.12)
|
||||
? rowHighlightColor
|
||||
: Color.clear
|
||||
)
|
||||
)
|
||||
|
|
@ -2539,10 +2941,7 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.26),
|
||||
Color.black.opacity(0.14),
|
||||
],
|
||||
colors: popupOverlayGradientColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
|
@ -2553,18 +2952,16 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.22),
|
||||
Color.white.opacity(0.06),
|
||||
],
|
||||
colors: popupBorderGradientColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 20, y: 10)
|
||||
.contentShape(Rectangle())
|
||||
.clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
|
||||
.shadow(color: popupShadowColor, radius: 20, y: 10)
|
||||
.contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityRespondsToUserInteraction(true)
|
||||
.accessibilityIdentifier("BrowserOmnibarSuggestions")
|
||||
|
|
@ -2621,6 +3018,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 +3260,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator: Coordinator,
|
||||
generation: Int
|
||||
) {
|
||||
let retryInterval: TimeInterval = 1.0 / 60.0
|
||||
// Don't schedule multiple overlapping retries.
|
||||
guard coordinator.attachRetryWorkItem == nil else { return }
|
||||
|
||||
|
|
@ -2874,7 +3293,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// Be generous here: bonsplit structural updates can keep a representable
|
||||
// container off-window longer than a few seconds under load.
|
||||
if coordinator.attachRetryCount < 400 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
|
||||
scheduleAttachRetry(
|
||||
webView,
|
||||
panel: panel,
|
||||
|
|
@ -2911,13 +3330,18 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
coordinator.attachRetryWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
let webView = panel.webView
|
||||
context.coordinator.panel = panel
|
||||
context.coordinator.webView = webView
|
||||
Self.applyWebViewFirstResponderPolicy(
|
||||
panel: panel,
|
||||
webView: webView,
|
||||
isPanelFocused: isPanelFocused
|
||||
)
|
||||
|
||||
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
|
||||
if shouldUseWindowPortal {
|
||||
|
|
@ -3165,6 +3589,26 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private static func applyWebViewFirstResponderPolicy(
|
||||
panel: BrowserPanel,
|
||||
webView: WKWebView,
|
||||
isPanelFocused: Bool
|
||||
) {
|
||||
guard let cmuxWebView = webView as? CmuxWebView else { return }
|
||||
let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
|
||||
if cmuxWebView.allowsFirstResponderAcquisition != next {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " +
|
||||
"suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
cmuxWebView.allowsFirstResponderAcquisition = next
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||||
coordinator.attachRetryWorkItem?.cancel()
|
||||
coordinator.attachRetryWorkItem = nil
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,41 @@ public enum PanelType: String, Codable, Sendable {
|
|||
case browser
|
||||
}
|
||||
|
||||
enum FocusFlashCurve: Equatable {
|
||||
case easeIn
|
||||
case easeOut
|
||||
}
|
||||
|
||||
struct FocusFlashSegment: Equatable {
|
||||
let delay: TimeInterval
|
||||
let duration: TimeInterval
|
||||
let targetOpacity: Double
|
||||
let curve: FocusFlashCurve
|
||||
}
|
||||
|
||||
enum FocusFlashPattern {
|
||||
static let values: [Double] = [0, 1, 0, 1, 0]
|
||||
static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1]
|
||||
static let duration: TimeInterval = 0.9
|
||||
static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn]
|
||||
static let ringInset: Double = 6
|
||||
static let ringCornerRadius: Double = 10
|
||||
|
||||
static var segments: [FocusFlashSegment] {
|
||||
let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1)
|
||||
return (0..<stepCount).map { index in
|
||||
let startTime = keyTimes[index]
|
||||
let endTime = keyTimes[index + 1]
|
||||
return FocusFlashSegment(
|
||||
delay: startTime * duration,
|
||||
duration: (endTime - startTime) * duration,
|
||||
targetOpacity: values[index + 1],
|
||||
curve: curves[index]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol for all panel types (terminal, browser, etc.)
|
||||
@MainActor
|
||||
public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID {
|
||||
|
|
@ -33,6 +68,9 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
|
|||
|
||||
/// Unfocus the panel
|
||||
func unfocus()
|
||||
|
||||
/// Trigger a focus flash animation for this panel.
|
||||
func triggerFlash()
|
||||
}
|
||||
|
||||
/// Extension providing default implementations
|
||||
|
|
|
|||
|
|
@ -85,15 +85,20 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
workingDirectory: String? = nil,
|
||||
portOrdinal: Int = 0,
|
||||
initialCommand: String? = nil,
|
||||
initialEnvironmentOverrides: [String: String] = [:]
|
||||
initialEnvironmentOverrides: [String: String] = [:],
|
||||
additionalEnvironment: [String: String] = [:]
|
||||
) {
|
||||
var mergedEnvironment = initialEnvironmentOverrides
|
||||
for (key, value) in additionalEnvironment {
|
||||
mergedEnvironment[key] = value
|
||||
}
|
||||
let surface = TerminalSurface(
|
||||
tabId: workspaceId,
|
||||
context: context,
|
||||
configTemplate: configTemplate,
|
||||
workingDirectory: workingDirectory,
|
||||
initialCommand: initialCommand,
|
||||
initialEnvironmentOverrides: initialEnvironmentOverrides
|
||||
initialEnvironmentOverrides: mergedEnvironment
|
||||
)
|
||||
surface.portOrdinal = portOrdinal
|
||||
self.init(workspaceId: workspaceId, surface: surface)
|
||||
|
|
@ -139,8 +144,11 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
|
||||
func close() {
|
||||
// The surface will be cleaned up by its deinit
|
||||
// Just unfocus before closing
|
||||
// Detach from the window portal on real close so stale hosted views
|
||||
// cannot remain above browser panes after split close.
|
||||
unfocus()
|
||||
hostedView.setVisibleInUI(false)
|
||||
TerminalWindowPortalRegistry.detach(hostedView: hostedView)
|
||||
}
|
||||
|
||||
func requestViewReattach() {
|
||||
|
|
|
|||
|
|
@ -15,37 +15,26 @@ struct TerminalPanelView: View {
|
|||
let onTriggerFlash: () -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ final class PostHogAnalytics {
|
|||
private let host = "https://us.i.posthog.com"
|
||||
|
||||
private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC"
|
||||
private let lastActiveHourUTCKey = "posthog.lastActiveHourUTC"
|
||||
|
||||
private var didStart = false
|
||||
private var activeCheckTimer: Timer?
|
||||
|
||||
private var isEnabled: Bool {
|
||||
guard TelemetrySettings.enabledForCurrentLaunch else { return false }
|
||||
#if DEBUG
|
||||
// Avoid polluting production analytics while iterating locally.
|
||||
return ProcessInfo.processInfo.environment["CMUX_POSTHOG_ENABLE"] == "1"
|
||||
|
|
@ -39,8 +41,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.
|
||||
|
||||
|
|
@ -53,6 +56,7 @@ final class PostHogAnalytics {
|
|||
guard let self else { return }
|
||||
guard NSApp.isActive else { return }
|
||||
self.trackDailyActive(reason: "activeTimer")
|
||||
self.trackHourlyActive(reason: "activeTimer")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,20 +72,55 @@ 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()
|
||||
}
|
||||
|
||||
func trackHourlyActive(reason: String) {
|
||||
startIfNeeded()
|
||||
guard didStart else { return }
|
||||
|
||||
let hour = utcHourString(Date())
|
||||
let defaults = UserDefaults.standard
|
||||
if defaults.string(forKey: lastActiveHourUTCKey) == hour {
|
||||
return
|
||||
}
|
||||
|
||||
defaults.set(hour, forKey: lastActiveHourUTCKey)
|
||||
|
||||
PostHogSDK.shared.capture(
|
||||
"cmux_hourly_active",
|
||||
properties: Self.hourlyActiveProperties(
|
||||
hourUTC: hour,
|
||||
reason: reason,
|
||||
infoDictionary: Bundle.main.infoDictionary ?? [:]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func flush() {
|
||||
guard didStart else { return }
|
||||
PostHogSDK.shared.flush()
|
||||
}
|
||||
|
||||
private func utcHourString(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .iso8601)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func utcDayString(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .iso8601)
|
||||
|
|
@ -90,4 +129,47 @@ 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 static func hourlyActiveProperties(
|
||||
hourUTC: String,
|
||||
reason: String,
|
||||
infoDictionary: [String: Any]
|
||||
) -> [String: Any] {
|
||||
var properties: [String: Any] = [
|
||||
"hour_utc": hourUTC,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
Sources/SentryHelper.swift
Normal file
45
Sources/SentryHelper.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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) {
|
||||
guard TelemetrySettings.enabledForCurrentLaunch else { return }
|
||||
let crumb = Breadcrumb(level: .info, category: category)
|
||||
crumb.message = message
|
||||
crumb.data = data
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
}
|
||||
|
||||
private func sentryCaptureMessage(
|
||||
_ message: String,
|
||||
level: SentryLevel,
|
||||
category: String,
|
||||
data: [String: Any]?,
|
||||
contextKey: String?
|
||||
) {
|
||||
guard TelemetrySettings.enabledForCurrentLaunch else { return }
|
||||
_ = SentrySDK.capture(message: message) { scope in
|
||||
scope.setLevel(level)
|
||||
scope.setTag(value: category, key: "category")
|
||||
if let data {
|
||||
scope.setContext(value: data, key: contextKey ?? category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sentryCaptureWarning(
|
||||
_ message: String,
|
||||
category: String = "ui",
|
||||
data: [String: Any]? = nil,
|
||||
contextKey: String? = nil
|
||||
) {
|
||||
sentryCaptureMessage(message, level: .warning, category: category, data: data, contextKey: contextKey)
|
||||
}
|
||||
|
||||
func sentryCaptureError(
|
||||
_ message: String,
|
||||
category: String = "ui",
|
||||
data: [String: Any]? = nil,
|
||||
contextKey: String? = nil
|
||||
) {
|
||||
sentryCaptureMessage(message, level: .error, category: category, data: data, contextKey: contextKey)
|
||||
}
|
||||
474
Sources/SessionPersistence.swift
Normal file
474
Sources/SessionPersistence.swift
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import CoreGraphics
|
||||
import Foundation
|
||||
import Bonsplit
|
||||
|
||||
enum SessionSnapshotSchema {
|
||||
static let currentVersion = 1
|
||||
}
|
||||
|
||||
enum SessionPersistencePolicy {
|
||||
static let defaultSidebarWidth: Double = 200
|
||||
static let minimumSidebarWidth: Double = 186
|
||||
static let maximumSidebarWidth: Double = 600
|
||||
static let minimumWindowWidth: Double = 300
|
||||
static let minimumWindowHeight: Double = 200
|
||||
static let autosaveInterval: TimeInterval = 8.0
|
||||
static let maxWindowsPerSnapshot: Int = 12
|
||||
static let maxWorkspacesPerWindow: Int = 128
|
||||
static let maxPanelsPerWorkspace: Int = 512
|
||||
static let maxScrollbackLinesPerTerminal: Int = 4000
|
||||
static let maxScrollbackCharactersPerTerminal: Int = 400_000
|
||||
|
||||
static func sanitizedSidebarWidth(_ candidate: Double?) -> Double {
|
||||
let fallback = defaultSidebarWidth
|
||||
guard let candidate, candidate.isFinite else { return fallback }
|
||||
return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth)
|
||||
}
|
||||
|
||||
static func truncatedScrollback(_ text: String?) -> String? {
|
||||
guard let text, !text.isEmpty else { return nil }
|
||||
if text.count <= maxScrollbackCharactersPerTerminal {
|
||||
return text
|
||||
}
|
||||
let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal)
|
||||
let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart)
|
||||
return String(text[safeStart...])
|
||||
}
|
||||
|
||||
/// If truncation starts in the middle of an ANSI CSI escape sequence, advance
|
||||
/// to the first printable character after that sequence to avoid replaying
|
||||
/// malformed control bytes.
|
||||
private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index {
|
||||
guard initialStart > text.startIndex else { return initialStart }
|
||||
let escape = "\u{001B}"
|
||||
|
||||
guard let lastEscape = text[..<initialStart].lastIndex(of: Character(escape)) else {
|
||||
return initialStart
|
||||
}
|
||||
let csiMarker = text.index(after: lastEscape)
|
||||
guard csiMarker < text.endIndex, text[csiMarker] == "[" else {
|
||||
return initialStart
|
||||
}
|
||||
|
||||
// If a final CSI byte exists before the truncation boundary, we are not
|
||||
// inside a partial sequence.
|
||||
if csiFinalByteIndex(in: text, from: csiMarker, upperBound: initialStart) != nil {
|
||||
return initialStart
|
||||
}
|
||||
|
||||
// We are inside a CSI sequence. Skip to the first character after the
|
||||
// sequence terminator if it exists.
|
||||
guard let final = csiFinalByteIndex(in: text, from: csiMarker, upperBound: text.endIndex) else {
|
||||
return initialStart
|
||||
}
|
||||
let next = text.index(after: final)
|
||||
return next < text.endIndex ? next : text.endIndex
|
||||
}
|
||||
|
||||
private static func csiFinalByteIndex(
|
||||
in text: String,
|
||||
from csiMarker: String.Index,
|
||||
upperBound: String.Index
|
||||
) -> String.Index? {
|
||||
var index = text.index(after: csiMarker)
|
||||
while index < upperBound {
|
||||
guard let scalar = text[index].unicodeScalars.first?.value else {
|
||||
index = text.index(after: index)
|
||||
continue
|
||||
}
|
||||
if scalar >= 0x40, scalar <= 0x7E {
|
||||
return index
|
||||
}
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionRestorePolicy {
|
||||
static func isRunningUnderAutomatedTests(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
if environment["CMUX_UI_TEST_MODE"] == "1" {
|
||||
return true
|
||||
}
|
||||
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
|
||||
return true
|
||||
}
|
||||
if environment["XCTestConfigurationFilePath"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["XCTestBundlePath"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["XCTestSessionIdentifier"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["XCInjectBundle"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["XCInjectBundleInto"] != nil {
|
||||
return true
|
||||
}
|
||||
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func shouldAttemptRestore(
|
||||
arguments: [String] = CommandLine.arguments,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" {
|
||||
return false
|
||||
}
|
||||
if isRunningUnderAutomatedTests(environment: environment) {
|
||||
return false
|
||||
}
|
||||
|
||||
let extraArgs = arguments
|
||||
.dropFirst()
|
||||
.filter { !$0.hasPrefix("-psn_") }
|
||||
|
||||
// Any explicit launch argument is treated as an explicit open intent.
|
||||
return extraArgs.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionRectSnapshot: Codable, Equatable, Sendable {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let width: Double
|
||||
let height: Double
|
||||
|
||||
init(x: Double, y: Double, width: Double, height: Double) {
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
init(_ rect: CGRect) {
|
||||
self.x = Double(rect.origin.x)
|
||||
self.y = Double(rect.origin.y)
|
||||
self.width = Double(rect.size.width)
|
||||
self.height = Double(rect.size.height)
|
||||
}
|
||||
|
||||
var cgRect: CGRect {
|
||||
CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionDisplaySnapshot: Codable, Sendable {
|
||||
var displayID: UInt32?
|
||||
var frame: SessionRectSnapshot?
|
||||
var visibleFrame: SessionRectSnapshot?
|
||||
}
|
||||
|
||||
enum SessionSidebarSelection: String, Codable, Sendable, Equatable {
|
||||
case tabs
|
||||
case notifications
|
||||
|
||||
init(selection: SidebarSelection) {
|
||||
switch selection {
|
||||
case .tabs:
|
||||
self = .tabs
|
||||
case .notifications:
|
||||
self = .notifications
|
||||
}
|
||||
}
|
||||
|
||||
var sidebarSelection: SidebarSelection {
|
||||
switch self {
|
||||
case .tabs:
|
||||
return .tabs
|
||||
case .notifications:
|
||||
return .notifications
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionSidebarSnapshot: Codable, Sendable {
|
||||
var isVisible: Bool
|
||||
var selection: SessionSidebarSelection
|
||||
var width: Double?
|
||||
}
|
||||
|
||||
struct SessionStatusEntrySnapshot: Codable, Sendable {
|
||||
var key: String
|
||||
var value: String
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var timestamp: TimeInterval
|
||||
}
|
||||
|
||||
struct SessionLogEntrySnapshot: Codable, Sendable {
|
||||
var message: String
|
||||
var level: String
|
||||
var source: String?
|
||||
var timestamp: TimeInterval
|
||||
}
|
||||
|
||||
struct SessionProgressSnapshot: Codable, Sendable {
|
||||
var value: Double
|
||||
var label: String?
|
||||
}
|
||||
|
||||
struct SessionGitBranchSnapshot: Codable, Sendable {
|
||||
var branch: String
|
||||
var isDirty: Bool
|
||||
}
|
||||
|
||||
struct SessionTerminalPanelSnapshot: Codable, Sendable {
|
||||
var workingDirectory: String?
|
||||
var scrollback: String?
|
||||
}
|
||||
|
||||
struct SessionBrowserPanelSnapshot: Codable, Sendable {
|
||||
var urlString: String?
|
||||
var shouldRenderWebView: Bool
|
||||
var pageZoom: Double
|
||||
var developerToolsVisible: Bool
|
||||
var backHistoryURLStrings: [String]?
|
||||
var forwardHistoryURLStrings: [String]?
|
||||
}
|
||||
|
||||
struct SessionPanelSnapshot: Codable, Sendable {
|
||||
var id: UUID
|
||||
var type: PanelType
|
||||
var title: String?
|
||||
var customTitle: String?
|
||||
var directory: String?
|
||||
var isPinned: Bool
|
||||
var isManuallyUnread: Bool
|
||||
var gitBranch: SessionGitBranchSnapshot?
|
||||
var listeningPorts: [Int]
|
||||
var ttyName: String?
|
||||
var terminal: SessionTerminalPanelSnapshot?
|
||||
var browser: SessionBrowserPanelSnapshot?
|
||||
}
|
||||
|
||||
enum SessionSplitOrientation: String, Codable, Sendable {
|
||||
case horizontal
|
||||
case vertical
|
||||
|
||||
init(_ orientation: SplitOrientation) {
|
||||
switch orientation {
|
||||
case .horizontal:
|
||||
self = .horizontal
|
||||
case .vertical:
|
||||
self = .vertical
|
||||
}
|
||||
}
|
||||
|
||||
var splitOrientation: SplitOrientation {
|
||||
switch self {
|
||||
case .horizontal:
|
||||
return .horizontal
|
||||
case .vertical:
|
||||
return .vertical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionPaneLayoutSnapshot: Codable, Sendable {
|
||||
var panelIds: [UUID]
|
||||
var selectedPanelId: UUID?
|
||||
}
|
||||
|
||||
struct SessionSplitLayoutSnapshot: Codable, Sendable {
|
||||
var orientation: SessionSplitOrientation
|
||||
var dividerPosition: Double
|
||||
var first: SessionWorkspaceLayoutSnapshot
|
||||
var second: SessionWorkspaceLayoutSnapshot
|
||||
}
|
||||
|
||||
indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable {
|
||||
case pane(SessionPaneLayoutSnapshot)
|
||||
case split(SessionSplitLayoutSnapshot)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case pane
|
||||
case split
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
switch type {
|
||||
case "pane":
|
||||
self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane))
|
||||
case "split":
|
||||
self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)")
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .pane(let pane):
|
||||
try container.encode("pane", forKey: .type)
|
||||
try container.encode(pane, forKey: .pane)
|
||||
case .split(let split):
|
||||
try container.encode("split", forKey: .type)
|
||||
try container.encode(split, forKey: .split)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionWorkspaceSnapshot: Codable, Sendable {
|
||||
var processTitle: String
|
||||
var customTitle: String?
|
||||
var customColor: String?
|
||||
var isPinned: Bool
|
||||
var currentDirectory: String
|
||||
var focusedPanelId: UUID?
|
||||
var layout: SessionWorkspaceLayoutSnapshot
|
||||
var panels: [SessionPanelSnapshot]
|
||||
var statusEntries: [SessionStatusEntrySnapshot]
|
||||
var logEntries: [SessionLogEntrySnapshot]
|
||||
var progress: SessionProgressSnapshot?
|
||||
var gitBranch: SessionGitBranchSnapshot?
|
||||
}
|
||||
|
||||
struct SessionTabManagerSnapshot: Codable, Sendable {
|
||||
var selectedWorkspaceIndex: Int?
|
||||
var workspaces: [SessionWorkspaceSnapshot]
|
||||
}
|
||||
|
||||
struct SessionWindowSnapshot: Codable, Sendable {
|
||||
var frame: SessionRectSnapshot?
|
||||
var display: SessionDisplaySnapshot?
|
||||
var tabManager: SessionTabManagerSnapshot
|
||||
var sidebar: SessionSidebarSnapshot
|
||||
}
|
||||
|
||||
struct AppSessionSnapshot: Codable, Sendable {
|
||||
var version: Int
|
||||
var createdAt: TimeInterval
|
||||
var windows: [SessionWindowSnapshot]
|
||||
}
|
||||
|
||||
enum SessionPersistenceStore {
|
||||
static func load(fileURL: URL? = nil) -> AppSessionSnapshot? {
|
||||
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil }
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
let decoder = JSONDecoder()
|
||||
guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil }
|
||||
guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil }
|
||||
guard !snapshot.windows.isEmpty else { return nil }
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool {
|
||||
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false }
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(snapshot)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func removeSnapshot(fileURL: URL? = nil) {
|
||||
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return }
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
static func defaultSnapshotFileURL(
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
appSupportDirectory: URL? = nil
|
||||
) -> URL? {
|
||||
let resolvedAppSupport: URL
|
||||
if let appSupportDirectory {
|
||||
resolvedAppSupport = appSupportDirectory
|
||||
} else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
resolvedAppSupport = discovered
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? bundleIdentifier!
|
||||
: "com.cmuxterm.app"
|
||||
let safeBundleId = bundleId.replacingOccurrences(
|
||||
of: "[^A-Za-z0-9._-]",
|
||||
with: "_",
|
||||
options: .regularExpression
|
||||
)
|
||||
return resolvedAppSupport
|
||||
.appendingPathComponent("cmux", isDirectory: true)
|
||||
.appendingPathComponent("session-\(safeBundleId).json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionScrollbackReplayStore {
|
||||
static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE"
|
||||
private static let directoryName = "cmux-session-scrollback"
|
||||
private static let ansiEscape = "\u{001B}"
|
||||
private static let ansiReset = "\u{001B}[0m"
|
||||
|
||||
static func replayEnvironment(
|
||||
for scrollback: String?,
|
||||
tempDirectory: URL = FileManager.default.temporaryDirectory
|
||||
) -> [String: String] {
|
||||
guard let replayText = normalizedScrollback(scrollback) else { return [:] }
|
||||
guard let replayFileURL = writeReplayFile(
|
||||
contents: replayText,
|
||||
tempDirectory: tempDirectory
|
||||
) else {
|
||||
return [:]
|
||||
}
|
||||
return [environmentKey: replayFileURL.path]
|
||||
}
|
||||
|
||||
private static func normalizedScrollback(_ scrollback: String?) -> String? {
|
||||
guard let scrollback else { return nil }
|
||||
guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil }
|
||||
guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil }
|
||||
return ansiSafeReplayText(truncated)
|
||||
}
|
||||
|
||||
/// Preserve ANSI color state safely across replay boundaries.
|
||||
private static func ansiSafeReplayText(_ text: String) -> String {
|
||||
guard text.contains(ansiEscape) else { return text }
|
||||
var output = text
|
||||
if !output.hasPrefix(ansiReset) {
|
||||
output = ansiReset + output
|
||||
}
|
||||
if !output.hasSuffix(ansiReset) {
|
||||
output += ansiReset
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? {
|
||||
guard let data = contents.data(using: .utf8) else { return nil }
|
||||
let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
let fileURL = directory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: false)
|
||||
.appendingPathExtension("txt")
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
return fileURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@ import SwiftUI
|
|||
|
||||
@MainActor
|
||||
final class SidebarSelectionState: ObservableObject {
|
||||
@Published var selection: SidebarSelection = .tabs
|
||||
}
|
||||
@Published var selection: SidebarSelection
|
||||
|
||||
init(selection: SidebarSelection = .tabs) {
|
||||
self.selection = selection
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import Foundation
|
||||
#if canImport(Security)
|
||||
import Security
|
||||
#endif
|
||||
|
||||
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 +21,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,45 +36,444 @@ 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 a local file."
|
||||
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 directoryName = "cmux"
|
||||
static let fileName = "socket-control-password"
|
||||
private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion"
|
||||
private static let keychainMigrationVersion = 1
|
||||
private static let legacyKeychainService = "com.cmuxterm.app.socket-control"
|
||||
private static let legacyKeychainAccount = "local-socket-password"
|
||||
private struct LazyKeychainFallbackCache {
|
||||
var hasLoaded = false
|
||||
var password: String?
|
||||
}
|
||||
private static let lazyKeychainFallbackLock = NSLock()
|
||||
private static var lazyKeychainFallbackCache = LazyKeychainFallbackCache()
|
||||
|
||||
static func configuredPassword(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileURL: URL? = nil,
|
||||
allowLazyKeychainFallback: Bool = false,
|
||||
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
|
||||
) -> String? {
|
||||
if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) {
|
||||
return envPassword
|
||||
}
|
||||
let filePassword: String?
|
||||
do {
|
||||
filePassword = try loadPassword(fileURL: fileURL)
|
||||
} catch {
|
||||
filePassword = nil
|
||||
}
|
||||
if let filePassword {
|
||||
return filePassword
|
||||
}
|
||||
guard allowLazyKeychainFallback else {
|
||||
return nil
|
||||
}
|
||||
return cachedLazyKeychainFallbackPassword(loadKeychainPassword: loadKeychainPassword)
|
||||
}
|
||||
|
||||
static func hasConfiguredPassword(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileURL: URL? = nil,
|
||||
allowLazyKeychainFallback: Bool = false,
|
||||
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
|
||||
) -> Bool {
|
||||
guard let configured = configuredPassword(
|
||||
environment: environment,
|
||||
fileURL: fileURL,
|
||||
allowLazyKeychainFallback: allowLazyKeychainFallback,
|
||||
loadKeychainPassword: loadKeychainPassword
|
||||
) else { return false }
|
||||
return !configured.isEmpty
|
||||
}
|
||||
|
||||
static func verify(
|
||||
password candidate: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileURL: URL? = nil,
|
||||
allowLazyKeychainFallback: Bool = false,
|
||||
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
|
||||
) -> Bool {
|
||||
guard let expected = configuredPassword(
|
||||
environment: environment,
|
||||
fileURL: fileURL,
|
||||
allowLazyKeychainFallback: allowLazyKeychainFallback,
|
||||
loadKeychainPassword: loadKeychainPassword
|
||||
), !expected.isEmpty else {
|
||||
return false
|
||||
}
|
||||
return expected == candidate
|
||||
}
|
||||
|
||||
static func migrateLegacyKeychainPasswordIfNeeded(
|
||||
defaults: UserDefaults = .standard,
|
||||
fileURL: URL? = nil,
|
||||
loadLegacyPassword: () -> String? = { loadLegacyPasswordFromKeychain() },
|
||||
deleteLegacyPassword: () -> Bool = { deleteLegacyPasswordFromKeychain() }
|
||||
) {
|
||||
guard defaults.integer(forKey: keychainMigrationDefaultsKey) < keychainMigrationVersion else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let legacyPassword = normalized(loadLegacyPassword()) else {
|
||||
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if try loadPassword(fileURL: fileURL) == nil {
|
||||
try savePassword(legacyPassword, fileURL: fileURL)
|
||||
}
|
||||
guard deleteLegacyPassword() else {
|
||||
return
|
||||
}
|
||||
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
|
||||
} catch {
|
||||
// Leave migration unset so it retries on next launch.
|
||||
}
|
||||
}
|
||||
|
||||
static func loadPassword(fileURL: URL? = nil) throws -> String? {
|
||||
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
||||
return nil
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
return nil
|
||||
}
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard let password = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return normalized(password)
|
||||
}
|
||||
|
||||
static func savePassword(_ password: String, fileURL: URL? = nil) throws {
|
||||
let normalized = password.trimmingCharacters(in: .newlines)
|
||||
if normalized.isEmpty {
|
||||
try clearPassword(fileURL: fileURL)
|
||||
return
|
||||
}
|
||||
|
||||
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
||||
throw NSError(
|
||||
domain: NSCocoaErrorDomain,
|
||||
code: NSFileNoSuchFileError,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to resolve socket password file path."]
|
||||
)
|
||||
}
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
let data = Data(normalized.utf8)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
|
||||
}
|
||||
|
||||
static func clearPassword(fileURL: URL? = nil) throws {
|
||||
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
||||
return
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
return
|
||||
}
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
static func resetLazyKeychainFallbackCacheForTests() {
|
||||
lazyKeychainFallbackLock.lock()
|
||||
lazyKeychainFallbackCache = LazyKeychainFallbackCache()
|
||||
lazyKeychainFallbackLock.unlock()
|
||||
}
|
||||
|
||||
static func defaultPasswordFileURL(
|
||||
appSupportDirectory: URL? = nil,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL? {
|
||||
let resolvedAppSupport: URL
|
||||
if let appSupportDirectory {
|
||||
resolvedAppSupport = appSupportDirectory
|
||||
} else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
resolvedAppSupport = discovered
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
return resolvedAppSupport
|
||||
.appendingPathComponent(directoryName, isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func loadLegacyPasswordFromKeychain() -> String? {
|
||||
#if canImport(Security)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: legacyKeychainService,
|
||||
kSecAttrAccount: legacyKeychainAccount,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func deleteLegacyPasswordFromKeychain() -> Bool {
|
||||
#if canImport(Security)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: legacyKeychainService,
|
||||
kSecAttrAccount: legacyKeychainAccount,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func cachedLazyKeychainFallbackPassword(
|
||||
loadKeychainPassword: () -> String?
|
||||
) -> String? {
|
||||
lazyKeychainFallbackLock.lock()
|
||||
defer { lazyKeychainFallbackLock.unlock() }
|
||||
if lazyKeychainFallbackCache.hasLoaded {
|
||||
return lazyKeychainFallbackCache.password
|
||||
}
|
||||
lazyKeychainFallbackCache.hasLoaded = true
|
||||
lazyKeychainFallbackCache.password = normalized(loadKeychainPassword())
|
||||
return lazyKeychainFallbackCache.password
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .newlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
struct SocketControlSettings {
|
||||
static let appStorageKey = "socketControlMode"
|
||||
static let legacyEnabledKey = "socketControlEnabled"
|
||||
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
|
||||
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
|
||||
static let launchTagEnvKey = "CMUX_TAG"
|
||||
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
|
||||
|
||||
/// 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 launchTag(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> String? {
|
||||
guard let raw = environment[launchTagEnvKey] else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func shouldBlockUntaggedDebugLaunch(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
|
||||
) -> Bool {
|
||||
guard isDebugBuild else { return false }
|
||||
if isRunningUnderXCTest(environment: environment) {
|
||||
return false
|
||||
}
|
||||
// XCUITest launches the app as a separate process without XCTest env vars,
|
||||
// so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var.
|
||||
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") {
|
||||
return false
|
||||
}
|
||||
|
||||
guard bundleIdentifier == baseDebugBundleIdentifier else {
|
||||
return false
|
||||
}
|
||||
|
||||
return launchTag(environment: environment) == nil
|
||||
}
|
||||
|
||||
static func isRunningUnderXCTest(environment: [String: String]) -> Bool {
|
||||
let indicators = [
|
||||
"XCTestConfigurationFilePath",
|
||||
"XCTestBundlePath",
|
||||
"XCTestSessionIdentifier",
|
||||
"XCInjectBundle",
|
||||
"XCInjectBundleInto",
|
||||
]
|
||||
if indicators.contains(where: { key in
|
||||
guard let value = environment[key] else { return false }
|
||||
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 +487,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
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,19 @@ struct TerminalNotification: Identifiable, Hashable {
|
|||
|
||||
@MainActor
|
||||
final class TerminalNotificationStore: ObservableObject {
|
||||
private struct TabSurfaceKey: Hashable {
|
||||
let tabId: UUID
|
||||
let surfaceId: UUID?
|
||||
}
|
||||
|
||||
private struct NotificationIndexes {
|
||||
var unreadCount = 0
|
||||
var unreadCountByTabId: [UUID: Int] = [:]
|
||||
var unreadByTabSurface = Set<TabSurfaceKey>()
|
||||
var latestUnreadByTabId: [UUID: TerminalNotification] = [:]
|
||||
var latestByTabId: [UUID: TerminalNotification] = [:]
|
||||
}
|
||||
|
||||
static let shared = TerminalNotificationStore()
|
||||
|
||||
static let categoryIdentifier = "com.cmuxterm.app.userNotification"
|
||||
|
|
@ -78,6 +91,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
|
||||
@Published private(set) var notifications: [TerminalNotification] = [] {
|
||||
didSet {
|
||||
indexes = Self.buildIndexes(for: notifications)
|
||||
refreshDockBadge()
|
||||
}
|
||||
}
|
||||
|
|
@ -86,8 +100,28 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
private var hasRequestedAuthorization = false
|
||||
private var hasPromptedForSettings = false
|
||||
private var userDefaultsObserver: NSObjectProtocol?
|
||||
private let settingsPromptWindowRetryDelay: TimeInterval = 0.5
|
||||
private let settingsPromptWindowRetryLimit = 20
|
||||
private var notificationSettingsWindowProvider: () -> NSWindow? = {
|
||||
NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
private var notificationSettingsAlertFactory: () -> NSAlert = {
|
||||
NSAlert()
|
||||
}
|
||||
private var notificationSettingsScheduler: (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void = {
|
||||
delay,
|
||||
block in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
private var notificationSettingsURLOpener: (URL) -> Void = { url in
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
private var indexes = NotificationIndexes()
|
||||
|
||||
private init() {
|
||||
indexes = Self.buildIndexes(for: notifications)
|
||||
userDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: nil,
|
||||
|
|
@ -124,26 +158,29 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
}
|
||||
|
||||
var unreadCount: Int {
|
||||
notifications.filter { !$0.isRead }.count
|
||||
indexes.unreadCount
|
||||
}
|
||||
|
||||
func unreadCount(forTabId tabId: UUID) -> Int {
|
||||
notifications.filter { $0.tabId == tabId && !$0.isRead }.count
|
||||
indexes.unreadCountByTabId[tabId] ?? 0
|
||||
}
|
||||
|
||||
func hasUnreadNotification(forTabId tabId: UUID, surfaceId: UUID?) -> Bool {
|
||||
notifications.contains { $0.tabId == tabId && $0.surfaceId == surfaceId && !$0.isRead }
|
||||
indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId))
|
||||
}
|
||||
|
||||
func latestNotification(forTabId tabId: UUID) -> TerminalNotification? {
|
||||
if let unread = notifications.first(where: { $0.tabId == tabId && !$0.isRead }) {
|
||||
return unread
|
||||
}
|
||||
return notifications.first(where: { $0.tabId == tabId })
|
||||
indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId]
|
||||
}
|
||||
|
||||
func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) {
|
||||
clearNotifications(forTabId: tabId, surfaceId: surfaceId)
|
||||
var updated = notifications
|
||||
var idsToClear: [String] = []
|
||||
updated.removeAll { existing in
|
||||
guard existing.tabId == tabId, existing.surfaceId == surfaceId else { return false }
|
||||
idsToClear.append(existing.id.uuidString)
|
||||
return true
|
||||
}
|
||||
|
||||
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
|
||||
let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId)
|
||||
|
|
@ -151,6 +188,11 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
let isFocusedPanel = isActiveTab && isFocusedSurface
|
||||
let isAppFocused = AppFocusState.isAppFocused()
|
||||
if isAppFocused && isFocusedPanel {
|
||||
if !idsToClear.isEmpty {
|
||||
notifications = updated
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -168,101 +210,136 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
createdAt: Date(),
|
||||
isRead: false
|
||||
)
|
||||
notifications.insert(notification, at: 0)
|
||||
updated.insert(notification, at: 0)
|
||||
notifications = updated
|
||||
if !idsToClear.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
|
||||
}
|
||||
scheduleUserNotification(notification)
|
||||
}
|
||||
|
||||
func markRead(id: UUID) {
|
||||
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
|
||||
if notifications[index].isRead { return }
|
||||
notifications[index].isRead = true
|
||||
var updated = notifications
|
||||
guard let index = updated.firstIndex(where: { $0.id == id }) else { return }
|
||||
guard !updated[index].isRead else { return }
|
||||
updated[index].isRead = true
|
||||
notifications = updated
|
||||
center.removeDeliveredNotifications(withIdentifiers: [id.uuidString])
|
||||
}
|
||||
|
||||
func markRead(forTabId tabId: UUID) {
|
||||
var updated = notifications
|
||||
var idsToClear: [String] = []
|
||||
for index in notifications.indices {
|
||||
if notifications[index].tabId == tabId && !notifications[index].isRead {
|
||||
notifications[index].isRead = true
|
||||
idsToClear.append(notifications[index].id.uuidString)
|
||||
for index in updated.indices {
|
||||
if updated[index].tabId == tabId && !updated[index].isRead {
|
||||
updated[index].isRead = true
|
||||
idsToClear.append(updated[index].id.uuidString)
|
||||
}
|
||||
}
|
||||
if !idsToClear.isEmpty {
|
||||
notifications = updated
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(forTabId tabId: UUID, surfaceId: UUID?) {
|
||||
var updated = notifications
|
||||
var idsToClear: [String] = []
|
||||
for index in notifications.indices {
|
||||
if notifications[index].tabId == tabId,
|
||||
notifications[index].surfaceId == surfaceId,
|
||||
!notifications[index].isRead {
|
||||
notifications[index].isRead = true
|
||||
idsToClear.append(notifications[index].id.uuidString)
|
||||
for index in updated.indices {
|
||||
if updated[index].tabId == tabId,
|
||||
updated[index].surfaceId == surfaceId,
|
||||
!updated[index].isRead {
|
||||
updated[index].isRead = true
|
||||
idsToClear.append(updated[index].id.uuidString)
|
||||
}
|
||||
}
|
||||
if !idsToClear.isEmpty {
|
||||
notifications = updated
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
|
||||
}
|
||||
}
|
||||
|
||||
func markUnread(forTabId tabId: UUID) {
|
||||
for index in notifications.indices {
|
||||
if notifications[index].tabId == tabId {
|
||||
notifications[index].isRead = false
|
||||
var updated = notifications
|
||||
var didChange = false
|
||||
for index in updated.indices {
|
||||
if updated[index].tabId == tabId, updated[index].isRead {
|
||||
updated[index].isRead = false
|
||||
didChange = true
|
||||
}
|
||||
}
|
||||
if didChange {
|
||||
notifications = updated
|
||||
}
|
||||
}
|
||||
|
||||
func markAllRead() {
|
||||
var updated = notifications
|
||||
var idsToClear: [String] = []
|
||||
for index in notifications.indices {
|
||||
if !notifications[index].isRead {
|
||||
notifications[index].isRead = true
|
||||
idsToClear.append(notifications[index].id.uuidString)
|
||||
for index in updated.indices {
|
||||
if !updated[index].isRead {
|
||||
updated[index].isRead = true
|
||||
idsToClear.append(updated[index].id.uuidString)
|
||||
}
|
||||
}
|
||||
if !idsToClear.isEmpty {
|
||||
notifications = updated
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(id: UUID) {
|
||||
notifications.removeAll { $0.id == id }
|
||||
var updated = notifications
|
||||
let originalCount = updated.count
|
||||
updated.removeAll { $0.id == id }
|
||||
guard updated.count != originalCount else { return }
|
||||
notifications = updated
|
||||
center.removeDeliveredNotifications(withIdentifiers: [id.uuidString])
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
guard !notifications.isEmpty else { return }
|
||||
let ids = notifications.map { $0.id.uuidString }
|
||||
notifications.removeAll()
|
||||
if !ids.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: ids)
|
||||
}
|
||||
center.removeDeliveredNotifications(withIdentifiers: ids)
|
||||
center.removePendingNotificationRequests(withIdentifiers: ids)
|
||||
}
|
||||
|
||||
func clearNotifications(forTabId tabId: UUID, surfaceId: UUID?) {
|
||||
let ids = notifications
|
||||
.filter { $0.tabId == tabId && $0.surfaceId == surfaceId }
|
||||
.map { $0.id.uuidString }
|
||||
notifications.removeAll { $0.tabId == tabId && $0.surfaceId == surfaceId }
|
||||
if !ids.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: ids)
|
||||
center.removePendingNotificationRequests(withIdentifiers: ids)
|
||||
var updated: [TerminalNotification] = []
|
||||
updated.reserveCapacity(notifications.count)
|
||||
var idsToClear: [String] = []
|
||||
for notification in notifications {
|
||||
if notification.tabId == tabId, notification.surfaceId == surfaceId {
|
||||
idsToClear.append(notification.id.uuidString)
|
||||
} else {
|
||||
updated.append(notification)
|
||||
}
|
||||
}
|
||||
guard !idsToClear.isEmpty else { return }
|
||||
notifications = updated
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
|
||||
}
|
||||
|
||||
func clearNotifications(forTabId tabId: UUID) {
|
||||
let ids = notifications
|
||||
.filter { $0.tabId == tabId }
|
||||
.map { $0.id.uuidString }
|
||||
notifications.removeAll { $0.tabId == tabId }
|
||||
if !ids.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: ids)
|
||||
center.removePendingNotificationRequests(withIdentifiers: ids)
|
||||
var updated: [TerminalNotification] = []
|
||||
updated.reserveCapacity(notifications.count)
|
||||
var idsToClear: [String] = []
|
||||
for notification in notifications {
|
||||
if notification.tabId == tabId {
|
||||
idsToClear.append(notification.id.uuidString)
|
||||
} else {
|
||||
updated.append(notification)
|
||||
}
|
||||
}
|
||||
guard !idsToClear.isEmpty else { return }
|
||||
notifications = updated
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
|
||||
}
|
||||
|
||||
private func scheduleUserNotification(_ notification: TerminalNotification) {
|
||||
|
|
@ -336,20 +413,94 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, !self.hasPromptedForSettings else { return }
|
||||
self.hasPromptedForSettings = true
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Enable Notifications for cmux"
|
||||
alert.informativeText = "Notifications are disabled for cmux. Enable them in System Settings to see alerts."
|
||||
alert.addButton(withTitle: "Open Settings")
|
||||
alert.addButton(withTitle: "Not Now")
|
||||
let response = alert.runModal()
|
||||
guard response == .alertFirstButtonReturn else { return }
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
self.presentNotificationSettingsPrompt(attempt: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentNotificationSettingsPrompt(attempt: Int) {
|
||||
guard let window = notificationSettingsWindowProvider() else {
|
||||
guard attempt < settingsPromptWindowRetryLimit else {
|
||||
// If no window is available after retries, allow a future denied callback
|
||||
// to prompt again when the app has a key/main window.
|
||||
hasPromptedForSettings = false
|
||||
return
|
||||
}
|
||||
notificationSettingsScheduler(settingsPromptWindowRetryDelay) { [weak self] in
|
||||
self?.presentNotificationSettingsPrompt(attempt: attempt + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let alert = notificationSettingsAlertFactory()
|
||||
alert.messageText = "Enable Notifications for cmux"
|
||||
alert.informativeText = "Notifications are disabled for cmux. Enable them in System Settings to see alerts."
|
||||
alert.addButton(withTitle: "Open Settings")
|
||||
alert.addButton(withTitle: "Not Now")
|
||||
alert.beginSheetModal(for: window) { [weak self] response in
|
||||
guard response == .alertFirstButtonReturn,
|
||||
let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
|
||||
return
|
||||
}
|
||||
self?.notificationSettingsURLOpener(url)
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildIndexes(for notifications: [TerminalNotification]) -> NotificationIndexes {
|
||||
var indexes = NotificationIndexes()
|
||||
for notification in notifications {
|
||||
if indexes.latestByTabId[notification.tabId] == nil {
|
||||
indexes.latestByTabId[notification.tabId] = notification
|
||||
}
|
||||
guard !notification.isRead else { continue }
|
||||
indexes.unreadCount += 1
|
||||
indexes.unreadCountByTabId[notification.tabId, default: 0] += 1
|
||||
indexes.unreadByTabSurface.insert(
|
||||
TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId)
|
||||
)
|
||||
if indexes.latestUnreadByTabId[notification.tabId] == nil {
|
||||
indexes.latestUnreadByTabId[notification.tabId] = notification
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func configureNotificationSettingsPromptHooksForTesting(
|
||||
windowProvider: @escaping () -> NSWindow?,
|
||||
alertFactory: @escaping () -> NSAlert,
|
||||
scheduler: @escaping (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void,
|
||||
urlOpener: @escaping (URL) -> Void
|
||||
) {
|
||||
notificationSettingsWindowProvider = windowProvider
|
||||
notificationSettingsAlertFactory = alertFactory
|
||||
notificationSettingsScheduler = scheduler
|
||||
notificationSettingsURLOpener = urlOpener
|
||||
hasPromptedForSettings = false
|
||||
}
|
||||
|
||||
func resetNotificationSettingsPromptHooksForTesting() {
|
||||
notificationSettingsWindowProvider = { NSApp.keyWindow ?? NSApp.mainWindow }
|
||||
notificationSettingsAlertFactory = { NSAlert() }
|
||||
notificationSettingsScheduler = { delay, block in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
notificationSettingsURLOpener = { url in
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
hasPromptedForSettings = false
|
||||
}
|
||||
|
||||
func promptToEnableNotificationsForTesting() {
|
||||
promptToEnableNotifications()
|
||||
}
|
||||
|
||||
func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) {
|
||||
self.notifications = notifications
|
||||
}
|
||||
#endif
|
||||
|
||||
private func refreshDockBadge() {
|
||||
let label = Self.dockBadgeLabel(
|
||||
unreadCount: unreadCount,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,8 @@ class UpdateController {
|
|||
private(set) var updater: SPUUpdater
|
||||
private let userDriver: UpdateDriver
|
||||
private var installCancellable: AnyCancellable?
|
||||
private var attemptInstallCancellable: AnyCancellable?
|
||||
private var didObserveAttemptUpdateProgress: Bool = false
|
||||
private var noUpdateDismissCancellable: AnyCancellable?
|
||||
private var noUpdateDismissWorkItem: DispatchWorkItem?
|
||||
private var readyCheckWorkItem: DispatchWorkItem?
|
||||
|
|
@ -46,6 +48,7 @@ class UpdateController {
|
|||
|
||||
deinit {
|
||||
installCancellable?.cancel()
|
||||
attemptInstallCancellable?.cancel()
|
||||
noUpdateDismissCancellable?.cancel()
|
||||
noUpdateDismissWorkItem?.cancel()
|
||||
readyCheckWorkItem?.cancel()
|
||||
|
|
@ -107,6 +110,35 @@ class UpdateController {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check for updates and auto-confirm install if one is found.
|
||||
func attemptUpdate() {
|
||||
stopAttemptUpdateMonitoring()
|
||||
didObserveAttemptUpdateProgress = false
|
||||
|
||||
attemptInstallCancellable = viewModel.$state
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self else { return }
|
||||
|
||||
if state.isInstallable || !state.isIdle {
|
||||
self.didObserveAttemptUpdateProgress = true
|
||||
}
|
||||
|
||||
if case .updateAvailable = state {
|
||||
UpdateLogStore.shared.append("attemptUpdate auto-confirming available update")
|
||||
state.confirm()
|
||||
return
|
||||
}
|
||||
|
||||
guard self.didObserveAttemptUpdateProgress, !state.isInstallable else {
|
||||
return
|
||||
}
|
||||
self.stopAttemptUpdateMonitoring()
|
||||
}
|
||||
|
||||
checkForUpdates()
|
||||
}
|
||||
|
||||
/// Check for updates (used by the menu item).
|
||||
@objc func checkForUpdates() {
|
||||
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
|
||||
|
|
@ -175,6 +207,12 @@ class UpdateController {
|
|||
return true
|
||||
}
|
||||
|
||||
private func stopAttemptUpdateMonitoring() {
|
||||
attemptInstallCancellable?.cancel()
|
||||
attemptInstallCancellable = nil
|
||||
didObserveAttemptUpdateProgress = false
|
||||
}
|
||||
|
||||
private func installNoUpdateDismissObserver() {
|
||||
noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState)
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ extension UpdateDriver: SPUUpdaterDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
|
||||
AppDelegate.shared?.persistSessionForUpdateRelaunch()
|
||||
TerminalController.shared.stop()
|
||||
NSApp.invalidateRestorableState()
|
||||
for window in NSApp.windows {
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ struct TitlebarControlButton<Content: View>: View {
|
|||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
let baseButton = Button(action: action) {
|
||||
content()
|
||||
.frame(width: config.buttonSize, height: config.buttonSize)
|
||||
.contentShape(Rectangle())
|
||||
|
|
@ -209,7 +209,12 @@ struct TitlebarControlButton<Content: View>: View {
|
|||
.frame(width: config.buttonSize, height: config.buttonSize)
|
||||
.contentShape(Rectangle())
|
||||
.background(hoverBackground)
|
||||
.onHover { isHovering = $0 }
|
||||
|
||||
if titlebarControlsShouldTrackButtonHover(config: config) {
|
||||
baseButton.onHover { isHovering = $0 }
|
||||
} else {
|
||||
baseButton
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -333,7 +338,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)
|
||||
}
|
||||
|
|
@ -341,7 +346,7 @@ struct TitlebarControlsView: View {
|
|||
.frame(width: config.buttonSize, height: config.buttonSize)
|
||||
}
|
||||
.accessibilityIdentifier("titlebarControl.showNotifications")
|
||||
.overlay(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }.allowsHitTesting(false))
|
||||
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
|
||||
.accessibilityLabel("Notifications")
|
||||
.help(KeyboardShortcutSettings.Action.showNotifications.tooltip("Show notifications"))
|
||||
|
||||
|
|
@ -657,12 +662,49 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
struct TitlebarControlsLayoutSnapshot: Equatable {
|
||||
let contentSize: NSSize
|
||||
let containerHeight: CGFloat
|
||||
let yOffset: CGFloat
|
||||
}
|
||||
|
||||
func titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyleConfig) -> Bool {
|
||||
config.hoverBackground
|
||||
}
|
||||
|
||||
func titlebarControlsShouldScheduleForViewSizeChange(
|
||||
previous: NSSize,
|
||||
current: NSSize,
|
||||
tolerance: CGFloat = 0.5
|
||||
) -> Bool {
|
||||
guard current.width > 0, current.height > 0 else { return false }
|
||||
guard previous.width > 0, previous.height > 0 else { return true }
|
||||
return abs(previous.width - current.width) > tolerance
|
||||
|| abs(previous.height - current.height) > tolerance
|
||||
}
|
||||
|
||||
func titlebarControlsShouldApplyLayout(
|
||||
previous: TitlebarControlsLayoutSnapshot?,
|
||||
next: TitlebarControlsLayoutSnapshot,
|
||||
tolerance: CGFloat = 0.5
|
||||
) -> Bool {
|
||||
guard let previous else { return true }
|
||||
return abs(previous.contentSize.width - next.contentSize.width) > tolerance
|
||||
|| abs(previous.contentSize.height - next.contentSize.height) > tolerance
|
||||
|| abs(previous.containerHeight - next.containerHeight) > tolerance
|
||||
|| abs(previous.yOffset - next.yOffset) > tolerance
|
||||
}
|
||||
|
||||
final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate {
|
||||
private let hostingView: NonDraggableHostingView<TitlebarControlsView>
|
||||
private let containerView = NSView()
|
||||
private let notificationStore: TerminalNotificationStore
|
||||
private lazy var notificationsPopover: NSPopover = makeNotificationsPopover()
|
||||
private var pendingSizeUpdate = false
|
||||
private var fittingSizeNeedsRefresh = true
|
||||
private var cachedFittingSize: NSSize?
|
||||
private var lastObservedViewSize: NSSize = .zero
|
||||
private var lastAppliedLayoutSnapshot: TitlebarControlsLayoutSnapshot?
|
||||
private let viewModel = TitlebarControlsViewModel()
|
||||
private var userDefaultsObserver: NSObjectProtocol?
|
||||
var popoverIsShownForTesting: Bool { notificationsPopover.isShown }
|
||||
|
|
@ -696,10 +738,10 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.scheduleSizeUpdate()
|
||||
self?.scheduleSizeUpdate(invalidateFittingSize: true)
|
||||
}
|
||||
|
||||
scheduleSizeUpdate()
|
||||
scheduleSizeUpdate(invalidateFittingSize: true)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
|
@ -714,15 +756,26 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
|
||||
override func viewDidAppear() {
|
||||
super.viewDidAppear()
|
||||
scheduleSizeUpdate()
|
||||
scheduleSizeUpdate(invalidateFittingSize: true)
|
||||
}
|
||||
|
||||
override func viewDidLayout() {
|
||||
super.viewDidLayout()
|
||||
scheduleSizeUpdate()
|
||||
let currentViewSize = view.bounds.size
|
||||
guard titlebarControlsShouldScheduleForViewSizeChange(
|
||||
previous: lastObservedViewSize,
|
||||
current: currentViewSize
|
||||
) else {
|
||||
return
|
||||
}
|
||||
lastObservedViewSize = currentViewSize
|
||||
scheduleSizeUpdate(invalidateFittingSize: true)
|
||||
}
|
||||
|
||||
private func scheduleSizeUpdate() {
|
||||
private func scheduleSizeUpdate(invalidateFittingSize: Bool = false) {
|
||||
if invalidateFittingSize {
|
||||
fittingSizeNeedsRefresh = true
|
||||
}
|
||||
guard !pendingSizeUpdate else { return }
|
||||
pendingSizeUpdate = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
|
|
@ -732,14 +785,33 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
}
|
||||
|
||||
private func updateSize() {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
hostingView.layoutSubtreeIfNeeded()
|
||||
let contentSize = hostingView.fittingSize
|
||||
let contentSize: NSSize
|
||||
if fittingSizeNeedsRefresh || cachedFittingSize == nil {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
hostingView.layoutSubtreeIfNeeded()
|
||||
cachedFittingSize = hostingView.fittingSize
|
||||
fittingSizeNeedsRefresh = false
|
||||
}
|
||||
contentSize = cachedFittingSize ?? .zero
|
||||
|
||||
guard contentSize.width > 0, contentSize.height > 0 else { return }
|
||||
let titlebarHeight = view.window.map { window in
|
||||
window.frame.height - window.contentLayoutRect.height
|
||||
} ?? contentSize.height
|
||||
let containerHeight = max(contentSize.height, titlebarHeight)
|
||||
let yOffset = max(0, (containerHeight - contentSize.height) / 2.0)
|
||||
let nextLayoutSnapshot = TitlebarControlsLayoutSnapshot(
|
||||
contentSize: contentSize,
|
||||
containerHeight: containerHeight,
|
||||
yOffset: yOffset
|
||||
)
|
||||
guard titlebarControlsShouldApplyLayout(
|
||||
previous: lastAppliedLayoutSnapshot,
|
||||
next: nextLayoutSnapshot
|
||||
) else {
|
||||
return
|
||||
}
|
||||
lastAppliedLayoutSnapshot = nextLayoutSnapshot
|
||||
preferredContentSize = NSSize(width: contentSize.width, height: containerHeight)
|
||||
containerView.frame = NSRect(x: 0, y: 0, width: contentSize.width, height: containerHeight)
|
||||
hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height)
|
||||
|
|
@ -905,11 +977,11 @@ private struct NotificationPopoverRow: View {
|
|||
Button(action: onOpen) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(notification.isRead ? Color.clear : Color.accentColor)
|
||||
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
||||
.stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
||||
)
|
||||
.padding(.top, 6)
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ class UpdateViewModel: ObservableObject {
|
|||
case .checking:
|
||||
return .secondary
|
||||
case .updateAvailable:
|
||||
return .accentColor
|
||||
return cmuxAccentColor()
|
||||
case .downloading, .extracting, .installing:
|
||||
return .secondary
|
||||
case .notFound:
|
||||
|
|
@ -147,7 +147,7 @@ class UpdateViewModel: ObservableObject {
|
|||
case .permissionRequest:
|
||||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
|
||||
case .updateAvailable:
|
||||
return .accentColor
|
||||
return cmuxAccentColor()
|
||||
case .notFound:
|
||||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue)
|
||||
case .error:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,347 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import SwiftUI
|
||||
|
||||
private func windowDragHandleFormatPoint(_ point: NSPoint) -> String {
|
||||
String(format: "(%.1f,%.1f)", point.x, point.y)
|
||||
}
|
||||
|
||||
private func windowDragHandleEventTypeDescription(_ eventType: NSEvent.EventType?) -> String {
|
||||
eventType.map { String(describing: $0) } ?? "nil"
|
||||
}
|
||||
|
||||
private enum WindowDragHandleBreadcrumbLimiter {
|
||||
private static let lock = NSLock()
|
||||
private static var lastEmissionByKey: [String: CFAbsoluteTime] = [:]
|
||||
|
||||
static func shouldEmit(key: String, minInterval: CFTimeInterval) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
if let previous = lastEmissionByKey[key], (now - previous) < minInterval {
|
||||
return false
|
||||
}
|
||||
lastEmissionByKey[key] = now
|
||||
if lastEmissionByKey.count > 128 {
|
||||
let staleThreshold = now - max(minInterval * 4, 60)
|
||||
lastEmissionByKey = lastEmissionByKey.filter { _, timestamp in
|
||||
timestamp >= staleThreshold
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func windowDragHandleEmitBreadcrumb(
|
||||
_ message: String,
|
||||
window: NSWindow?,
|
||||
eventType: NSEvent.EventType?,
|
||||
point: NSPoint,
|
||||
minInterval: CFTimeInterval = 10,
|
||||
extraData: [String: Any] = [:]
|
||||
) {
|
||||
let windowNumber = window?.windowNumber ?? -1
|
||||
let key = "\(message):\(windowNumber)"
|
||||
guard WindowDragHandleBreadcrumbLimiter.shouldEmit(key: key, minInterval: minInterval) else {
|
||||
return
|
||||
}
|
||||
|
||||
var data: [String: Any] = [
|
||||
"event_type": windowDragHandleEventTypeDescription(eventType),
|
||||
"point": windowDragHandleFormatPoint(point),
|
||||
"window_number": windowNumber,
|
||||
"window_present": window != nil
|
||||
]
|
||||
for (name, value) in extraData {
|
||||
data[name] = value
|
||||
}
|
||||
sentryBreadcrumb(message, category: "titlebar.drag", data: data)
|
||||
}
|
||||
|
||||
private func windowDragHandleShouldResolveActiveHitCapture(
|
||||
for eventType: NSEvent.EventType?,
|
||||
eventWindow: NSWindow?,
|
||||
dragHandleWindow: NSWindow?
|
||||
) -> Bool {
|
||||
// We only need active hit resolution for titlebar mouse-down handling.
|
||||
// During launch, NSApp.currentEvent can transiently point at a stale
|
||||
// leftMouseDown from outside this window (for example Finder/Dock
|
||||
// activation). Treat those as passive events so we never walk SwiftUI/
|
||||
// AppKit hierarchy while initial layout is mutating it.
|
||||
guard eventType == .leftMouseDown else {
|
||||
return false
|
||||
}
|
||||
guard let dragHandleWindow else {
|
||||
// Test-only views may not be attached to a window.
|
||||
return true
|
||||
}
|
||||
guard let eventWindow else {
|
||||
return false
|
||||
}
|
||||
return eventWindow === dragHandleWindow
|
||||
}
|
||||
|
||||
/// 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 enum WindowDragHandleAssociatedObjectKeys {
|
||||
private static let suppressionDepthToken = NSObject()
|
||||
|
||||
static let suppressionDepth = UnsafeRawPointer(Unmanaged.passUnretained(suppressionDepthToken).toOpaque())
|
||||
}
|
||||
|
||||
func beginWindowDragSuppression(window: NSWindow?) -> Int? {
|
||||
guard let window else { return nil }
|
||||
let current = windowDragSuppressionDepth(window: window)
|
||||
let next = current + 1
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
WindowDragHandleAssociatedObjectKeys.suppressionDepth,
|
||||
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,
|
||||
WindowDragHandleAssociatedObjectKeys.suppressionDepth,
|
||||
nil,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
} else {
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
WindowDragHandleAssociatedObjectKeys.suppressionDepth,
|
||||
NSNumber(value: next),
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func windowDragSuppressionDepth(window: NSWindow?) -> Int {
|
||||
guard let window,
|
||||
let value = objc_getAssociatedObject(window, WindowDragHandleAssociatedObjectKeys.suppressionDepth) 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
|
||||
}
|
||||
|
||||
/// 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,
|
||||
eventType: NSEvent.EventType? = NSApp.currentEvent?.type,
|
||||
eventWindow: NSWindow? = NSApp.currentEvent?.window
|
||||
) -> Bool {
|
||||
let dragHandleWindow = dragHandleView.window
|
||||
|
||||
// Suppression recovery runs first so stale depth is cleared even for
|
||||
// passive events — the associated-object reads/writes here are pure ObjC
|
||||
// runtime calls and cannot trigger Swift exclusive-access violations.
|
||||
if isWindowDragSuppressed(window: dragHandleWindow) {
|
||||
// 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: dragHandleWindow)
|
||||
windowDragHandleEmitBreadcrumb(
|
||||
"titlebar.dragHandle.suppression.recovered",
|
||||
window: dragHandleWindow,
|
||||
eventType: eventType,
|
||||
point: point,
|
||||
minInterval: 20,
|
||||
extraData: [
|
||||
"cleared_depth": clearedDepth
|
||||
]
|
||||
)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))"
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
let depth = windowDragSuppressionDepth(window: dragHandleWindow)
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Bail out before the view-hierarchy walk so we never re-enter SwiftUI
|
||||
// views during a layout pass — which causes exclusive-access crashes (#490).
|
||||
if !windowDragHandleShouldResolveActiveHitCapture(
|
||||
for: eventType,
|
||||
eventWindow: eventWindow,
|
||||
dragHandleWindow: dragHandleWindow
|
||||
) {
|
||||
#if DEBUG
|
||||
let eventTypeDescription = eventType.map { String(describing: $0) } ?? "nil"
|
||||
let eventWindowNumber = eventWindow?.windowNumber ?? -1
|
||||
let dragWindowNumber = dragHandleWindow?.windowNumber ?? -1
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=false reason=passiveEvent eventType=\(eventTypeDescription) eventWindow=\(eventWindowNumber) dragWindow=\(dragWindowNumber) 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
|
||||
}
|
||||
|
||||
let siblingSnapshot = Array(superview.subviews.reversed())
|
||||
|
||||
#if DEBUG
|
||||
let siblingCount = siblingSnapshot.count
|
||||
#endif
|
||||
|
||||
for sibling in siblingSnapshot {
|
||||
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
|
||||
windowDragHandleEmitBreadcrumb(
|
||||
"titlebar.dragHandle.hitTest.blockedBySiblingHit",
|
||||
window: dragHandleWindow,
|
||||
eventType: eventType,
|
||||
point: point,
|
||||
minInterval: 8,
|
||||
extraData: [
|
||||
"sibling_type": String(describing: type(of: sibling)),
|
||||
"hit_type": String(describing: type(of: hitView))
|
||||
]
|
||||
)
|
||||
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 +355,61 @@ 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 currentEvent = NSApp.currentEvent
|
||||
let shouldCapture = windowDragHandleShouldCaptureHit(
|
||||
point,
|
||||
in: self,
|
||||
eventType: currentEvent?.type,
|
||||
eventWindow: currentEvent?.window
|
||||
)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)"
|
||||
)
|
||||
#endif
|
||||
return shouldCapture ? self : nil
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let depth = windowDragSuppressionDepth(window: window)
|
||||
dlog(
|
||||
"titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)"
|
||||
)
|
||||
#endif
|
||||
|
||||
if event.clickCount >= 2 {
|
||||
let handled = performStandardTitlebarDoubleClick(window: window)
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)")
|
||||
#endif
|
||||
if handled {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard !isWindowDragSuppressed(window: window) else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
if let window {
|
||||
let previousMovableState = withTemporaryWindowMovableEnabled(window: window) {
|
||||
window.performDrag(with: event)
|
||||
}
|
||||
#if DEBUG
|
||||
let restored = previousMovableState.map { String($0) } ?? "nil"
|
||||
dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)")
|
||||
#endif
|
||||
} else {
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,10 +9,27 @@ struct WorkspaceContentView: View {
|
|||
let isWorkspaceVisible: Bool
|
||||
let isWorkspaceInputActive: Bool
|
||||
let workspacePortalPriority: Int
|
||||
@State private var config = GhosttyConfig.load()
|
||||
let onThemeRefreshRequest: ((
|
||||
_ reason: String,
|
||||
_ backgroundEventId: UInt64?,
|
||||
_ backgroundSource: String?,
|
||||
_ notificationPayloadHex: String?
|
||||
) -> Void)?
|
||||
@State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit")
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
|
||||
static func panelVisibleInUI(
|
||||
isWorkspaceVisible: Bool,
|
||||
isSelectedInPane: Bool,
|
||||
isFocused: Bool
|
||||
) -> Bool {
|
||||
guard isWorkspaceVisible else { return false }
|
||||
// During pane/tab reparenting, Bonsplit can transiently report selected=false
|
||||
// for the currently focused panel. Keep focused content visible to avoid blank frames.
|
||||
return isSelectedInPane || isFocused
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let appearance = PanelAppearance.fromConfig(config)
|
||||
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
|
||||
|
|
@ -41,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,29 @@ struct WorkspaceContentView: View {
|
|||
syncBonsplitNotificationBadges()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
|
||||
refreshGhosttyAppearanceConfig()
|
||||
GhosttyConfig.invalidateLoadCache()
|
||||
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 +173,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 +294,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 +330,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 +384,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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
48
ci_scripts/ci_post_clone.sh
Executable file
48
ci_scripts/ci_post_clone.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== ci_post_clone.sh ==="
|
||||
|
||||
# Initialize submodules (needed for vendor/bonsplit SPM package)
|
||||
echo "Initializing submodules..."
|
||||
git submodule update --init --recursive
|
||||
|
||||
# Get ghostty submodule SHA
|
||||
GHOSTTY_SHA=$(git -C "$CI_PRIMARY_REPOSITORY_PATH/ghostty" rev-parse HEAD)
|
||||
echo "Ghostty SHA: $GHOSTTY_SHA"
|
||||
|
||||
# Download pre-built xcframework from manaflow-ai/ghostty releases
|
||||
TAG="xcframework-$GHOSTTY_SHA"
|
||||
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
|
||||
|
||||
echo "Downloading xcframework from $URL"
|
||||
|
||||
MAX_RETRIES=30
|
||||
RETRY_DELAY=20
|
||||
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
if curl -fSL -o "$CI_PRIMARY_REPOSITORY_PATH/GhosttyKit.xcframework.tar.gz" "$URL"; then
|
||||
echo "Download succeeded on attempt $i"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq "$MAX_RETRIES" ]; then
|
||||
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
|
||||
sleep $RETRY_DELAY
|
||||
done
|
||||
|
||||
# Extract xcframework to project root
|
||||
echo "Extracting xcframework..."
|
||||
cd "$CI_PRIMARY_REPOSITORY_PATH"
|
||||
tar xzf GhosttyKit.xcframework.tar.gz
|
||||
rm GhosttyKit.xcframework.tar.gz
|
||||
test -d GhosttyKit.xcframework
|
||||
echo "GhosttyKit.xcframework extracted successfully"
|
||||
|
||||
# Download Metal toolchain (required for shader compilation)
|
||||
echo "Downloading Metal toolchain..."
|
||||
xcodebuild -downloadComponent MetalToolchain
|
||||
|
||||
echo "=== ci_post_clone.sh done ==="
|
||||
43
ci_scripts/ci_pre_xcodebuild.sh
Executable file
43
ci_scripts/ci_pre_xcodebuild.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$PWD}"
|
||||
cd "$ROOT"
|
||||
|
||||
echo "ci_pre_xcodebuild: repository root is $ROOT"
|
||||
|
||||
if [ -f "vendor/bonsplit/Package.swift" ]; then
|
||||
echo "ci_pre_xcodebuild: vendor/bonsplit already present"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "ci_pre_xcodebuild: attempting submodule init for vendor/bonsplit"
|
||||
git submodule sync --recursive || true
|
||||
git submodule update --init --recursive vendor/bonsplit || true
|
||||
fi
|
||||
|
||||
if [ ! -f "vendor/bonsplit/Package.swift" ]; then
|
||||
echo "ci_pre_xcodebuild: submodule not present, cloning fallback"
|
||||
rm -rf vendor/bonsplit
|
||||
mkdir -p vendor
|
||||
git clone --depth 1 https://github.com/manaflow-ai/bonsplit.git vendor/bonsplit
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
expected_sha="$(git ls-tree HEAD vendor/bonsplit | awk '{print $3}')"
|
||||
if [ -n "${expected_sha:-}" ]; then
|
||||
(
|
||||
cd vendor/bonsplit
|
||||
git fetch --depth 1 origin "$expected_sha" || true
|
||||
git checkout "$expected_sha" || true
|
||||
)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "vendor/bonsplit/Package.swift" ]; then
|
||||
echo "ci_pre_xcodebuild: missing vendor/bonsplit/Package.swift after recovery" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ci_pre_xcodebuild: vendor/bonsplit is ready"
|
||||
|
|
@ -8,6 +8,8 @@
|
|||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
|
|
|||
552
cmuxTests/AppDelegateShortcutRoutingTests.swift
Normal file
552
cmuxTests/AppDelegateShortcutRoutingTests.swift
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
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 testCmdShiftRRequestsRenameWorkspaceInCommandPalette() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer {
|
||||
closeWindow(withId: windowId)
|
||||
}
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
let workspaceExpectation = expectation(description: "Expected command palette rename workspace notification")
|
||||
var observedWorkspaceWindow: NSWindow?
|
||||
let workspaceToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteRenameWorkspaceRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { notification in
|
||||
observedWorkspaceWindow = notification.object as? NSWindow
|
||||
workspaceExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(workspaceToken) }
|
||||
|
||||
let renameTabExpectation = expectation(description: "Rename tab notification should not fire for Cmd+Shift+R")
|
||||
renameTabExpectation.isInverted = true
|
||||
let renameTabToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteRenameTabRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
renameTabExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(renameTabToken) }
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "r",
|
||||
modifiers: [.command, .shift],
|
||||
keyCode: 15, // kVK_ANSI_R
|
||||
windowNumber: window.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+Shift+R event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
wait(for: [workspaceExpectation, renameTabExpectation], timeout: 1.0)
|
||||
XCTAssertEqual(observedWorkspaceWindow?.windowNumber, window.windowNumber)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() {
|
||||
var showFallbackSettingsWindowCallCount = 0
|
||||
var activateApplicationCallCount = 0
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
activateApplicationCallCount += 1
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(showFallbackSettingsWindowCallCount, 1)
|
||||
XCTAssertEqual(activateApplicationCallCount, 1)
|
||||
}
|
||||
|
||||
func testPresentPreferencesWindowSupportsRepeatedCalls() {
|
||||
var showFallbackSettingsWindowCallCount = 0
|
||||
var activateApplicationCallCount = 0
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
activateApplicationCallCount += 1
|
||||
}
|
||||
)
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
activateApplicationCallCount += 1
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(showFallbackSettingsWindowCallCount, 2)
|
||||
XCTAssertEqual(activateApplicationCallCount, 2)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -694,3 +761,72 @@ final class CJKIMEKeyTextAccumulatorTests: XCTestCase {
|
|||
XCTAssertNil(view.keyTextAccumulatorForTesting)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Space release regression (Codex hold-to-talk in cmux)
|
||||
|
||||
@MainActor
|
||||
final class GhosttySpaceReleaseRegressionTests: XCTestCase {
|
||||
func testSyntheticSpaceReleaseCarriesUnshiftedCodepoint() {
|
||||
_ = NSApplication.shared
|
||||
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: nil,
|
||||
workingDirectory: nil
|
||||
)
|
||||
let hostedView = surface.hostedView
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer {
|
||||
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
|
||||
window.orderOut(nil)
|
||||
}
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(hostedView)
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
hostedView.setVisibleInUI(true)
|
||||
hostedView.setActive(true)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
var releaseEvent: ghostty_input_key_s?
|
||||
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
|
||||
if keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 49 {
|
||||
releaseEvent = keyEvent
|
||||
}
|
||||
}
|
||||
|
||||
let sent = hostedView.debugSendSyntheticKeyPressAndReleaseForUITest(
|
||||
characters: " ",
|
||||
charactersIgnoringModifiers: " ",
|
||||
keyCode: 49
|
||||
)
|
||||
XCTAssertTrue(sent, "Expected synthetic Space key press/release to be dispatched")
|
||||
|
||||
guard let releaseEvent else {
|
||||
XCTFail("Expected to capture synthetic Space key release event")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(releaseEvent.action, GHOSTTY_ACTION_RELEASE)
|
||||
XCTAssertEqual(releaseEvent.keycode, 49)
|
||||
XCTAssertEqual(releaseEvent.unshifted_codepoint, " ".unicodeScalars.first!.value)
|
||||
XCTAssertEqual(releaseEvent.consumed_mods.rawValue, GHOSTTY_MODS_NONE.rawValue)
|
||||
XCTAssertFalse(releaseEvent.composing)
|
||||
XCTAssertNil(releaseEvent.text)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,38 @@ import AppKit
|
|||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class SidebarPathFormatterTests: XCTestCase {
|
||||
func testShortenedPathReplacesExactHomeDirectory() {
|
||||
XCTAssertEqual(
|
||||
SidebarPathFormatter.shortenedPath(
|
||||
"/Users/example",
|
||||
homeDirectoryPath: "/Users/example"
|
||||
),
|
||||
"~"
|
||||
)
|
||||
}
|
||||
|
||||
func testShortenedPathReplacesHomeDirectoryPrefix() {
|
||||
XCTAssertEqual(
|
||||
SidebarPathFormatter.shortenedPath(
|
||||
"/Users/example/projects/cmux",
|
||||
homeDirectoryPath: "/Users/example"
|
||||
),
|
||||
"~/projects/cmux"
|
||||
)
|
||||
}
|
||||
|
||||
func testShortenedPathLeavesExternalPathUnchanged() {
|
||||
XCTAssertEqual(
|
||||
SidebarPathFormatter.shortenedPath(
|
||||
"/tmp/cmux",
|
||||
homeDirectoryPath: "/Users/example"
|
||||
),
|
||||
"/tmp/cmux"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class GhosttyConfigTests: XCTestCase {
|
||||
private struct RGB: Equatable {
|
||||
let red: Int
|
||||
|
|
@ -126,6 +158,64 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227))
|
||||
}
|
||||
|
||||
func testLoadCachesPerColorScheme() {
|
||||
GhosttyConfig.invalidateLoadCache()
|
||||
defer { GhosttyConfig.invalidateLoadCache() }
|
||||
|
||||
var loadCount = 0
|
||||
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { scheme in
|
||||
loadCount += 1
|
||||
var config = GhosttyConfig()
|
||||
config.fontFamily = "\(scheme)-\(loadCount)"
|
||||
return config
|
||||
}
|
||||
|
||||
let lightFirst = GhosttyConfig.load(
|
||||
preferredColorScheme: .light,
|
||||
loadFromDisk: loadFromDisk
|
||||
)
|
||||
let lightSecond = GhosttyConfig.load(
|
||||
preferredColorScheme: .light,
|
||||
loadFromDisk: loadFromDisk
|
||||
)
|
||||
let darkFirst = GhosttyConfig.load(
|
||||
preferredColorScheme: .dark,
|
||||
loadFromDisk: loadFromDisk
|
||||
)
|
||||
|
||||
XCTAssertEqual(loadCount, 2)
|
||||
XCTAssertEqual(lightFirst.fontFamily, "light-1")
|
||||
XCTAssertEqual(lightSecond.fontFamily, "light-1")
|
||||
XCTAssertEqual(darkFirst.fontFamily, "dark-2")
|
||||
}
|
||||
|
||||
func testLoadCacheInvalidationForcesReload() {
|
||||
GhosttyConfig.invalidateLoadCache()
|
||||
defer { GhosttyConfig.invalidateLoadCache() }
|
||||
|
||||
var loadCount = 0
|
||||
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { _ in
|
||||
loadCount += 1
|
||||
var config = GhosttyConfig()
|
||||
config.fontFamily = "reload-\(loadCount)"
|
||||
return config
|
||||
}
|
||||
|
||||
let first = GhosttyConfig.load(
|
||||
preferredColorScheme: .dark,
|
||||
loadFromDisk: loadFromDisk
|
||||
)
|
||||
GhosttyConfig.invalidateLoadCache()
|
||||
let second = GhosttyConfig.load(
|
||||
preferredColorScheme: .dark,
|
||||
loadFromDisk: loadFromDisk
|
||||
)
|
||||
|
||||
XCTAssertEqual(loadCount, 2)
|
||||
XCTAssertEqual(first.fontFamily, "reload-1")
|
||||
XCTAssertEqual(second.fontFamily, "reload-2")
|
||||
}
|
||||
|
||||
func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() {
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
||||
|
|
@ -162,7 +252,138 @@ 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 testScrollLagCaptureRequiresSustainedLag() {
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldCaptureScrollLagEvent(
|
||||
samples: 4,
|
||||
averageMs: 18,
|
||||
maxMs: 85,
|
||||
thresholdMs: 40,
|
||||
nowUptime: 1000,
|
||||
lastReportedUptime: nil
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldCaptureScrollLagEvent(
|
||||
samples: 10,
|
||||
averageMs: 6,
|
||||
maxMs: 85,
|
||||
thresholdMs: 40,
|
||||
nowUptime: 1000,
|
||||
lastReportedUptime: nil
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldCaptureScrollLagEvent(
|
||||
samples: 10,
|
||||
averageMs: 18,
|
||||
maxMs: 35,
|
||||
thresholdMs: 40,
|
||||
nowUptime: 1000,
|
||||
lastReportedUptime: nil
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldCaptureScrollLagEvent(
|
||||
samples: 10,
|
||||
averageMs: 18,
|
||||
maxMs: 85,
|
||||
thresholdMs: 40,
|
||||
nowUptime: 1000,
|
||||
lastReportedUptime: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testScrollLagCaptureRespectsCooldownWindow() {
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldCaptureScrollLagEvent(
|
||||
samples: 12,
|
||||
averageMs: 22,
|
||||
maxMs: 90,
|
||||
thresholdMs: 40,
|
||||
nowUptime: 1200,
|
||||
lastReportedUptime: 1005,
|
||||
cooldown: 300
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldCaptureScrollLagEvent(
|
||||
samples: 12,
|
||||
averageMs: 22,
|
||||
maxMs: 90,
|
||||
thresholdMs: 40,
|
||||
nowUptime: 1406,
|
||||
lastReportedUptime: 1005,
|
||||
cooldown: 300
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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 +394,7 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
}
|
||||
|
||||
defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
||||
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
||||
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testClaudeCodeIntegrationRespectsStoredPreference() {
|
||||
|
|
@ -193,6 +414,37 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testTelemetryDefaultsToEnabledWhenUnset() {
|
||||
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated user defaults suite")
|
||||
return
|
||||
}
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
defaults.removeObject(forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
||||
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testTelemetryRespectsStoredPreference() {
|
||||
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated user defaults suite")
|
||||
return
|
||||
}
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
defaults.set(true, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
||||
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
|
||||
|
||||
defaults.set(false, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
|
||||
XCTAssertFalse(TelemetrySettings.isEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
private func rgb255(_ color: NSColor) -> RGB {
|
||||
let srgb = color.usingColorSpace(.sRGB)!
|
||||
var red: CGFloat = 0
|
||||
|
|
@ -208,6 +460,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 +592,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 +764,353 @@ 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"
|
||||
)
|
||||
}
|
||||
|
||||
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
|
||||
XCTAssertTrue(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testUntaggedDebugBundleAllowedWithLaunchTag() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["CMUX_TAG": "tests-v1"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testTaggedDebugBundleAllowedWithoutLaunchTag() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug.tests-v1",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testReleaseBuildIgnoresLaunchTagGate() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testXCTestLaunchIgnoresLaunchTagGate() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["XCInjectBundle": "/tmp/fake.xctest"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testXCTestDyldLaunchIgnoresLaunchTagGate() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() {
|
||||
// XCUITest launches the app as a separate process without XCTest env vars.
|
||||
// The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment.
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["CMUX_UI_TEST_MODE": "1"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 testHourlyActivePropertiesIncludeVersionAndBuild() {
|
||||
let properties = PostHogAnalytics.hourlyActiveProperties(
|
||||
hourUTC: "2026-02-21T14",
|
||||
reason: "didBecomeActive",
|
||||
infoDictionary: [
|
||||
"CFBundleShortVersionString": "0.31.0",
|
||||
"CFBundleVersion": "230",
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
|
||||
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
|
||||
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
||||
XCTAssertEqual(properties["app_build"] as? String, "230")
|
||||
}
|
||||
|
||||
func testHourlyPropertiesOmitVersionFieldsWhenUnavailable() {
|
||||
let properties = PostHogAnalytics.hourlyActiveProperties(
|
||||
hourUTC: "2026-02-21T14",
|
||||
reason: "activeTimer",
|
||||
infoDictionary: [:]
|
||||
)
|
||||
|
||||
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
|
||||
XCTAssertEqual(properties["reason"] as? String, "activeTimer")
|
||||
XCTAssertNil(properties["app_version"])
|
||||
XCTAssertNil(properties["app_build"])
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
final class GhosttyMouseFocusTests: XCTestCase {
|
||||
func testShouldRequestFirstResponderForMouseFocusWhenEnabledAndWindowIsActive() {
|
||||
XCTAssertTrue(
|
||||
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
||||
focusFollowsMouseEnabled: true,
|
||||
pressedMouseButtons: 0,
|
||||
appIsActive: true,
|
||||
windowIsKey: true,
|
||||
alreadyFirstResponder: false,
|
||||
visibleInUI: true,
|
||||
hasUsableGeometry: true,
|
||||
hiddenInHierarchy: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testShouldNotRequestFirstResponderWhenFocusFollowsMouseDisabled() {
|
||||
XCTAssertFalse(
|
||||
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
||||
focusFollowsMouseEnabled: false,
|
||||
pressedMouseButtons: 0,
|
||||
appIsActive: true,
|
||||
windowIsKey: true,
|
||||
alreadyFirstResponder: false,
|
||||
visibleInUI: true,
|
||||
hasUsableGeometry: true,
|
||||
hiddenInHierarchy: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testShouldNotRequestFirstResponderDuringMouseDrag() {
|
||||
XCTAssertFalse(
|
||||
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
||||
focusFollowsMouseEnabled: true,
|
||||
pressedMouseButtons: 1,
|
||||
appIsActive: true,
|
||||
windowIsKey: true,
|
||||
alreadyFirstResponder: false,
|
||||
visibleInUI: true,
|
||||
hasUsableGeometry: true,
|
||||
hiddenInHierarchy: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testShouldNotRequestFirstResponderWhenViewCannotSafelyReceiveFocus() {
|
||||
XCTAssertFalse(
|
||||
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
||||
focusFollowsMouseEnabled: true,
|
||||
pressedMouseButtons: 0,
|
||||
appIsActive: true,
|
||||
windowIsKey: true,
|
||||
alreadyFirstResponder: false,
|
||||
visibleInUI: true,
|
||||
hasUsableGeometry: false,
|
||||
hiddenInHierarchy: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
|
||||
focusFollowsMouseEnabled: true,
|
||||
pressedMouseButtons: 0,
|
||||
appIsActive: true,
|
||||
windowIsKey: true,
|
||||
alreadyFirstResponder: false,
|
||||
visibleInUI: true,
|
||||
hasUsableGeometry: true,
|
||||
hiddenInHierarchy: true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
735
cmuxTests/SessionPersistenceTests.swift
Normal file
735
cmuxTests/SessionPersistenceTests.swift
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
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 testSessionAutosaveTickPolicySkipsWhenTerminating() {
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldRunSessionAutosaveTick(isTerminatingApp: false)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldRunSessionAutosaveTick(isTerminatingApp: true)
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionSnapshotSynchronousWritePolicy() {
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldWriteSessionSnapshotSynchronously(
|
||||
isTerminatingApp: false,
|
||||
includeScrollback: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldWriteSessionSnapshotSynchronously(
|
||||
isTerminatingApp: false,
|
||||
includeScrollback: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldWriteSessionSnapshotSynchronously(
|
||||
isTerminatingApp: true,
|
||||
includeScrollback: false
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldWriteSessionSnapshotSynchronously(
|
||||
isTerminatingApp: true,
|
||||
includeScrollback: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testUnchangedAutosaveFingerprintSkipsWithinStalenessWindow() {
|
||||
let now = Date()
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint(
|
||||
isTerminatingApp: false,
|
||||
includeScrollback: false,
|
||||
previousFingerprint: 1234,
|
||||
currentFingerprint: 1234,
|
||||
lastPersistedAt: now.addingTimeInterval(-5),
|
||||
now: now,
|
||||
maximumAutosaveSkippableInterval: 60
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testUnchangedAutosaveFingerprintDoesNotSkipAfterStalenessWindow() {
|
||||
let now = Date()
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint(
|
||||
isTerminatingApp: false,
|
||||
includeScrollback: false,
|
||||
previousFingerprint: 1234,
|
||||
currentFingerprint: 1234,
|
||||
lastPersistedAt: now.addingTimeInterval(-120),
|
||||
now: now,
|
||||
maximumAutosaveSkippableInterval: 60
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testUnchangedAutosaveFingerprintNeverSkipsTerminatingOrScrollbackWrites() {
|
||||
let now = Date()
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint(
|
||||
isTerminatingApp: true,
|
||||
includeScrollback: false,
|
||||
previousFingerprint: 1234,
|
||||
currentFingerprint: 1234,
|
||||
lastPersistedAt: now.addingTimeInterval(-1),
|
||||
now: now
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint(
|
||||
isTerminatingApp: false,
|
||||
includeScrollback: true,
|
||||
previousFingerprint: 1234,
|
||||
currentFingerprint: 1234,
|
||||
lastPersistedAt: now.addingTimeInterval(-1),
|
||||
now: now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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]
|
||||
)
|
||||
}
|
||||
}
|
||||
333
cmuxTests/SocketControlPasswordStoreTests.swift
Normal file
333
cmuxTests/SocketControlPasswordStoreTests.swift
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class SocketControlPasswordStoreTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSaveLoadAndClearRoundTripUsesFileStorage() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
||||
|
||||
XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
||||
|
||||
try SocketControlPasswordStore.savePassword("hunter2", fileURL: fileURL)
|
||||
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "hunter2")
|
||||
XCTAssertTrue(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
||||
|
||||
try SocketControlPasswordStore.clearPassword(fileURL: fileURL)
|
||||
XCTAssertNil(try SocketControlPasswordStore.loadPassword(fileURL: fileURL))
|
||||
XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
||||
}
|
||||
|
||||
func testConfiguredPasswordPrefersEnvironmentOverStoredFile() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
||||
try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL)
|
||||
|
||||
let environment = [SocketControlSettings.socketPasswordEnvKey: "env-secret"]
|
||||
let configured = SocketControlPasswordStore.configuredPassword(
|
||||
environment: environment,
|
||||
fileURL: fileURL
|
||||
)
|
||||
XCTAssertEqual(configured, "env-secret")
|
||||
}
|
||||
|
||||
func testConfiguredPasswordLazyKeychainFallbackReadsOnlyOnceAndCaches() {
|
||||
var readCount = 0
|
||||
|
||||
let withoutFallback = SocketControlPasswordStore.configuredPassword(
|
||||
environment: [:],
|
||||
fileURL: nil,
|
||||
allowLazyKeychainFallback: false,
|
||||
loadKeychainPassword: {
|
||||
readCount += 1
|
||||
return "legacy-secret"
|
||||
}
|
||||
)
|
||||
XCTAssertNil(withoutFallback)
|
||||
XCTAssertEqual(readCount, 0)
|
||||
|
||||
let firstWithFallback = SocketControlPasswordStore.configuredPassword(
|
||||
environment: [:],
|
||||
fileURL: nil,
|
||||
allowLazyKeychainFallback: true,
|
||||
loadKeychainPassword: {
|
||||
readCount += 1
|
||||
return "legacy-secret"
|
||||
}
|
||||
)
|
||||
XCTAssertEqual(firstWithFallback, "legacy-secret")
|
||||
XCTAssertEqual(readCount, 1)
|
||||
|
||||
let secondWithFallback = SocketControlPasswordStore.configuredPassword(
|
||||
environment: [:],
|
||||
fileURL: nil,
|
||||
allowLazyKeychainFallback: true,
|
||||
loadKeychainPassword: {
|
||||
readCount += 1
|
||||
return "new-secret"
|
||||
}
|
||||
)
|
||||
XCTAssertEqual(secondWithFallback, "legacy-secret")
|
||||
XCTAssertEqual(readCount, 1)
|
||||
}
|
||||
|
||||
func testConfiguredPasswordLazyKeychainFallbackCachesMissingValue() {
|
||||
var readCount = 0
|
||||
|
||||
let first = SocketControlPasswordStore.configuredPassword(
|
||||
environment: [:],
|
||||
fileURL: nil,
|
||||
allowLazyKeychainFallback: true,
|
||||
loadKeychainPassword: {
|
||||
readCount += 1
|
||||
return nil
|
||||
}
|
||||
)
|
||||
XCTAssertNil(first)
|
||||
XCTAssertEqual(readCount, 1)
|
||||
|
||||
let second = SocketControlPasswordStore.configuredPassword(
|
||||
environment: [:],
|
||||
fileURL: nil,
|
||||
allowLazyKeychainFallback: true,
|
||||
loadKeychainPassword: {
|
||||
readCount += 1
|
||||
return "should-not-be-read"
|
||||
}
|
||||
)
|
||||
XCTAssertNil(second)
|
||||
XCTAssertEqual(readCount, 1)
|
||||
}
|
||||
|
||||
func testConfiguredPasswordPrefersStoredFileOverLazyKeychainFallback() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
||||
try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL)
|
||||
|
||||
var readCount = 0
|
||||
let configured = SocketControlPasswordStore.configuredPassword(
|
||||
environment: [:],
|
||||
fileURL: fileURL,
|
||||
allowLazyKeychainFallback: true,
|
||||
loadKeychainPassword: {
|
||||
readCount += 1
|
||||
return "legacy-secret"
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(configured, "stored-secret")
|
||||
XCTAssertEqual(readCount, 0)
|
||||
}
|
||||
|
||||
func testHasConfiguredAndVerifyReuseSingleLazyKeychainRead() {
|
||||
var readCount = 0
|
||||
let loader = {
|
||||
readCount += 1
|
||||
return "legacy-secret"
|
||||
}
|
||||
|
||||
XCTAssertTrue(
|
||||
SocketControlPasswordStore.hasConfiguredPassword(
|
||||
environment: [:],
|
||||
fileURL: nil,
|
||||
allowLazyKeychainFallback: true,
|
||||
loadKeychainPassword: loader
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(readCount, 1)
|
||||
|
||||
XCTAssertTrue(
|
||||
SocketControlPasswordStore.verify(
|
||||
password: "legacy-secret",
|
||||
environment: [:],
|
||||
fileURL: nil,
|
||||
allowLazyKeychainFallback: true,
|
||||
loadKeychainPassword: loader
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(readCount, 1)
|
||||
}
|
||||
|
||||
func testDefaultPasswordFileURLUsesCmuxAppSupportPath() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let resolved = SocketControlPasswordStore.defaultPasswordFileURL(appSupportDirectory: tempDir)
|
||||
XCTAssertEqual(
|
||||
resolved?.path,
|
||||
tempDir.appendingPathComponent("cmux", isDirectory: true)
|
||||
.appendingPathComponent("socket-control-password", isDirectory: false).path
|
||||
)
|
||||
}
|
||||
|
||||
func testLegacyKeychainMigrationCopiesPasswordDeletesLegacyAndRunsOnlyOnce() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
||||
let defaultsSuiteName = "cmux-socket-password-migration-tests-\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: defaultsSuiteName) else {
|
||||
XCTFail("Expected isolated UserDefaults suite for migration test")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: defaultsSuiteName) }
|
||||
|
||||
var lookupCount = 0
|
||||
var deleteCount = 0
|
||||
|
||||
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(
|
||||
defaults: defaults,
|
||||
fileURL: fileURL,
|
||||
loadLegacyPassword: {
|
||||
lookupCount += 1
|
||||
return "legacy-secret"
|
||||
},
|
||||
deleteLegacyPassword: {
|
||||
deleteCount += 1
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
|
||||
XCTAssertEqual(lookupCount, 1)
|
||||
XCTAssertEqual(deleteCount, 1)
|
||||
|
||||
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(
|
||||
defaults: defaults,
|
||||
fileURL: fileURL,
|
||||
loadLegacyPassword: {
|
||||
lookupCount += 1
|
||||
return "new-value"
|
||||
},
|
||||
deleteLegacyPassword: {
|
||||
deleteCount += 1
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(lookupCount, 1)
|
||||
XCTAssertEqual(deleteCount, 1)
|
||||
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
|
||||
}
|
||||
}
|
||||
|
||||
final class CmuxCLIPathInstallerTests: XCTestCase {
|
||||
func testInstallAndUninstallRoundTripWithoutAdministratorPrivileges() throws {
|
||||
let fileManager = FileManager.default
|
||||
let root = fileManager.temporaryDirectory
|
||||
.appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: root) }
|
||||
|
||||
let bundledCLIURL = root
|
||||
.appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false)
|
||||
try fileManager.createDirectory(
|
||||
at: bundledCLIURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false)
|
||||
|
||||
var privilegedInstallCallCount = 0
|
||||
var privilegedUninstallCallCount = 0
|
||||
let installer = CmuxCLIPathInstaller(
|
||||
fileManager: fileManager,
|
||||
destinationURL: destinationURL,
|
||||
bundledCLIURLProvider: { bundledCLIURL },
|
||||
expectedBundledCLIPath: bundledCLIURL.path,
|
||||
privilegedInstaller: { _, _ in privilegedInstallCallCount += 1 },
|
||||
privilegedUninstaller: { _ in privilegedUninstallCallCount += 1 }
|
||||
)
|
||||
|
||||
let installOutcome = try installer.install()
|
||||
XCTAssertFalse(installOutcome.usedAdministratorPrivileges)
|
||||
XCTAssertEqual(privilegedInstallCallCount, 0)
|
||||
XCTAssertTrue(installer.isInstalled())
|
||||
XCTAssertEqual(
|
||||
try fileManager.destinationOfSymbolicLink(atPath: destinationURL.path),
|
||||
bundledCLIURL.path
|
||||
)
|
||||
|
||||
let uninstallOutcome = try installer.uninstall()
|
||||
XCTAssertFalse(uninstallOutcome.usedAdministratorPrivileges)
|
||||
XCTAssertTrue(uninstallOutcome.removedExistingEntry)
|
||||
XCTAssertEqual(privilegedUninstallCallCount, 0)
|
||||
XCTAssertFalse(fileManager.fileExists(atPath: destinationURL.path))
|
||||
XCTAssertFalse(installer.isInstalled())
|
||||
}
|
||||
|
||||
func testInstallFallsBackToAdministratorFlowWhenDestinationIsNotWritable() throws {
|
||||
let fileManager = FileManager.default
|
||||
let root = fileManager.temporaryDirectory
|
||||
.appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: root) }
|
||||
|
||||
let bundledCLIURL = root
|
||||
.appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false)
|
||||
try fileManager.createDirectory(
|
||||
at: bundledCLIURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false)
|
||||
let destinationDir = destinationURL.deletingLastPathComponent()
|
||||
try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true)
|
||||
try fileManager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: destinationDir.path)
|
||||
defer {
|
||||
try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path)
|
||||
}
|
||||
|
||||
var privilegedInstallCallCount = 0
|
||||
let installer = CmuxCLIPathInstaller(
|
||||
fileManager: fileManager,
|
||||
destinationURL: destinationURL,
|
||||
bundledCLIURLProvider: { bundledCLIURL },
|
||||
expectedBundledCLIPath: bundledCLIURL.path,
|
||||
privilegedInstaller: { sourceURL, privilegedDestinationURL in
|
||||
privilegedInstallCallCount += 1
|
||||
XCTAssertEqual(sourceURL.standardizedFileURL, bundledCLIURL.standardizedFileURL)
|
||||
XCTAssertEqual(privilegedDestinationURL.standardizedFileURL, destinationURL.standardizedFileURL)
|
||||
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path)
|
||||
try fileManager.createSymbolicLink(at: privilegedDestinationURL, withDestinationURL: sourceURL)
|
||||
}
|
||||
)
|
||||
|
||||
let installOutcome = try installer.install()
|
||||
XCTAssertTrue(installOutcome.usedAdministratorPrivileges)
|
||||
XCTAssertEqual(privilegedInstallCallCount, 1)
|
||||
XCTAssertTrue(installer.isInstalled())
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -200,6 +222,57 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class TitlebarControlsSizingPolicyTests: XCTestCase {
|
||||
func testSchedulePolicyRequiresMeaningfulViewSizeChange() {
|
||||
XCTAssertFalse(titlebarControlsShouldScheduleForViewSizeChange(previous: .zero, current: .zero))
|
||||
XCTAssertTrue(
|
||||
titlebarControlsShouldScheduleForViewSizeChange(
|
||||
previous: .zero,
|
||||
current: NSSize(width: 240, height: 38)
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
titlebarControlsShouldScheduleForViewSizeChange(
|
||||
previous: NSSize(width: 240, height: 38),
|
||||
current: NSSize(width: 240.2, height: 38.1)
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
titlebarControlsShouldScheduleForViewSizeChange(
|
||||
previous: NSSize(width: 240, height: 38),
|
||||
current: NSSize(width: 247, height: 38)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLayoutApplyPolicySkipsEquivalentSnapshots() {
|
||||
let baseline = TitlebarControlsLayoutSnapshot(
|
||||
contentSize: NSSize(width: 128, height: 22),
|
||||
containerHeight: 28,
|
||||
yOffset: 3
|
||||
)
|
||||
XCTAssertTrue(titlebarControlsShouldApplyLayout(previous: nil, next: baseline))
|
||||
XCTAssertFalse(titlebarControlsShouldApplyLayout(previous: baseline, next: baseline))
|
||||
|
||||
let changed = TitlebarControlsLayoutSnapshot(
|
||||
contentSize: NSSize(width: 132, height: 22),
|
||||
containerHeight: 28,
|
||||
yOffset: 3
|
||||
)
|
||||
XCTAssertTrue(titlebarControlsShouldApplyLayout(previous: baseline, next: changed))
|
||||
}
|
||||
}
|
||||
|
||||
final class TitlebarControlsHoverPolicyTests: XCTestCase {
|
||||
func testHoverTrackingOnlyEnabledForHoverBackgroundStyles() {
|
||||
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.classic.config))
|
||||
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.compact.config))
|
||||
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.roomy.config))
|
||||
XCTAssertTrue(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.pillGroup.config))
|
||||
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.softButtons.config))
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: ensure new terminal windows are born in full-size content mode so
|
||||
/// titlebar/content offsets are correct before the first resize.
|
||||
final class MainWindowLayoutStyleTests: XCTestCase {
|
||||
|
|
|
|||
49
cmuxTests/WorkspaceContentViewVisibilityTests.swift
Normal file
49
cmuxTests/WorkspaceContentViewVisibilityTests.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class WorkspaceContentViewVisibilityTests: XCTestCase {
|
||||
func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() {
|
||||
XCTAssertFalse(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: false,
|
||||
isSelectedInPane: true,
|
||||
isFocused: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsTrueForSelectedPanel() {
|
||||
XCTAssertTrue(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: true,
|
||||
isFocused: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() {
|
||||
XCTAssertTrue(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: false,
|
||||
isFocused: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() {
|
||||
XCTAssertFalse(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: false,
|
||||
isFocused: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
439
cmuxTests/WorkspaceManualUnreadTests.swift
Normal file
439
cmuxTests/WorkspaceManualUnreadTests.swift
Normal file
|
|
@ -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]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ final class AutomationSocketUITests: XCTestCase {
|
|||
private let defaultsDomain = "com.cmuxterm.app.debug"
|
||||
private let modeKey = "socketControlMode"
|
||||
private let legacyKey = "socketControlEnabled"
|
||||
private let launchTag = "ui-tests-automation-socket"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
|
@ -16,11 +17,12 @@ final class AutomationSocketUITests: XCTestCase {
|
|||
}
|
||||
|
||||
func testSocketToggleDisablesAndEnables() {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-\(modeKey)", "cmuxOnly"]
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
let app = configuredApp(mode: "cmuxOnly")
|
||||
app.launch()
|
||||
app.activate()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for socket toggle test. state=\(app.state.rawValue)"
|
||||
)
|
||||
|
||||
guard let resolvedPath = resolveSocketPath(timeout: 5.0) else {
|
||||
XCTFail("Expected control socket to exist")
|
||||
|
|
@ -32,16 +34,40 @@ final class AutomationSocketUITests: XCTestCase {
|
|||
}
|
||||
|
||||
func testSocketDisabledWhenSettingOff() {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-\(modeKey)", "off"]
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
let app = configuredApp(mode: "off")
|
||||
app.launch()
|
||||
app.activate()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for socket off test. state=\(app.state.rawValue)"
|
||||
)
|
||||
|
||||
XCTAssertTrue(waitForSocket(exists: false, timeout: 3.0))
|
||||
app.terminate()
|
||||
}
|
||||
|
||||
private func configuredApp(mode: String) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-\(modeKey)", mode]
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
|
||||
// Debug launches require a tag outside reload.sh; provide one in UITests so CI
|
||||
// does not fail with "Application ... does not have a process ID".
|
||||
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||
return app
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
// On busy UI runners the app can launch backgrounded; activate once before failing.
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
}
|
||||
|
||||
func testOmnibarSuggestionsAlignToPillAndCmdNP() {
|
||||
seedBrowserHistoryForTest()
|
||||
seedBrowserHistoryForTest(seedEntries: [
|
||||
SeedEntry(url: "https://example.com/", title: "Example Domain", visitCount: 12, typedCount: 4),
|
||||
SeedEntry(url: "https://example.org/", title: "Example Organization", visitCount: 9, typedCount: 3),
|
||||
SeedEntry(url: "https://go.dev/", title: "The Go Programming Language", visitCount: 6, typedCount: 1),
|
||||
])
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
|
|
@ -26,8 +30,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
// Keep suggestions deterministic for the keyboard-nav assertions.
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
// Focus omnibar.
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
|
@ -39,7 +42,10 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||
|
||||
// Type a query that matches the seeded URL.
|
||||
omnibar.typeText("exam")
|
||||
XCTAssertTrue(
|
||||
typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "exam", timeout: 6.0),
|
||||
"Expected omnibar suggestions to appear for 'exam'"
|
||||
)
|
||||
|
||||
// SwiftUI's accessibility typing for ScrollView can vary; match by identifier regardless of element type.
|
||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
|
|
@ -75,10 +81,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
XCTAssertTrue(row1.waitForExistence(timeout: 6.0))
|
||||
|
||||
app.typeKey("n", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1. value=\(String(describing: row1.value))")
|
||||
XCTAssertTrue(
|
||||
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
|
||||
"Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))"
|
||||
)
|
||||
|
||||
app.typeKey("p", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForRowSelected(row0, timeout: 2.0), "Expected Cmd+P to return to row 0. value=\(String(describing: row0.value))")
|
||||
XCTAssertTrue(
|
||||
waitForSuggestionRowToBeSelected(row0, timeout: 3.0),
|
||||
"Expected Cmd+P to move selection back to row 0. row0Value=\(String(describing: row0.value))"
|
||||
)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
|
|
@ -104,14 +116,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
// Keep suggestions deterministic.
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||
XCTAssertTrue(
|
||||
focusOmnibarWithCmdL(app: app, omnibar: omnibar, timeout: 4.0),
|
||||
"Expected Cmd+L to place keyboard focus in omnibar before typing"
|
||||
)
|
||||
|
||||
// Focus omnibar and navigate to example.com via autocompletion (row 0).
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
omnibar.typeText("exam")
|
||||
|
||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
|
|
@ -189,14 +203,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||
omnibar.typeText("go")
|
||||
XCTAssertTrue(
|
||||
typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "go", timeout: 6.0),
|
||||
"Expected omnibar suggestions to appear for 'go'"
|
||||
)
|
||||
|
||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
|
||||
|
|
@ -207,13 +221,22 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
XCTAssertTrue(row2.waitForExistence(timeout: 6.0))
|
||||
|
||||
app.typeKey("n", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1")
|
||||
XCTAssertTrue(
|
||||
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
|
||||
"Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))"
|
||||
)
|
||||
|
||||
app.typeKey("n", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForRowSelected(row2, timeout: 2.0), "Expected repeated Cmd+N to keep moving selection")
|
||||
XCTAssertTrue(
|
||||
waitForSuggestionRowToBeSelected(row2, timeout: 3.0),
|
||||
"Expected repeated Cmd+N to move selection to row 2. row2Value=\(String(describing: row2.value))"
|
||||
)
|
||||
|
||||
app.typeKey("p", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+P to move selection up")
|
||||
XCTAssertTrue(
|
||||
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
|
||||
"Expected Cmd+P to move selection back to row 1. row1Value=\(String(describing: row1.value))"
|
||||
)
|
||||
}
|
||||
|
||||
func testOmnibarShowsMultipleRowsWithoutClipping() {
|
||||
|
|
@ -225,8 +248,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
|
|
@ -253,8 +275,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
|
|
@ -315,8 +336,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||
|
|
@ -342,7 +362,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.typeKey("l", modifierFlags: [.command])
|
||||
app.typeText("lo")
|
||||
|
||||
let typedDeadline = Date().addingTimeInterval(4.0)
|
||||
let typedDeadline = Date().addingTimeInterval(7.0)
|
||||
var observedValue = ""
|
||||
var startsWithTypedPrefix = false
|
||||
while Date() < typedDeadline {
|
||||
|
|
@ -373,8 +393,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
|
|
@ -385,14 +404,46 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
|
||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
|
||||
let row0 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.0").firstMatch
|
||||
XCTAssertTrue(row0.waitForExistence(timeout: 4.0))
|
||||
|
||||
let row0Value = (row0.value as? String) ?? ""
|
||||
XCTAssertTrue(
|
||||
row0Value.localizedCaseInsensitiveContains("gmail"),
|
||||
"Expected autocomplete candidate to be first row. row0Value=\(row0Value)"
|
||||
)
|
||||
let rows: [XCUIElement] = (0...4).map {
|
||||
app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.\($0)").firstMatch
|
||||
}
|
||||
XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0))
|
||||
|
||||
var gmailRowIndex: Int?
|
||||
let gmailDeadline = Date().addingTimeInterval(4.0)
|
||||
while Date() < gmailDeadline {
|
||||
for (index, row) in rows.enumerated() where row.exists {
|
||||
let rowValue = (row.value as? String) ?? ""
|
||||
if rowValue.localizedCaseInsensitiveContains("gmail") {
|
||||
gmailRowIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if gmailRowIndex != nil {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
guard let gmailRowIndex else {
|
||||
let rowValues = rows.enumerated().compactMap { index, row -> String? in
|
||||
guard row.exists else { return nil }
|
||||
return "row\(index)=\((row.value as? String) ?? "<nil>")"
|
||||
}.joined(separator: ", ")
|
||||
XCTFail("Expected a Gmail suggestion row. rows=\(rowValues)")
|
||||
return
|
||||
}
|
||||
|
||||
if gmailRowIndex > 0 {
|
||||
let gmailRow = rows[gmailRowIndex]
|
||||
for _ in 0..<gmailRowIndex {
|
||||
app.typeKey("n", modifierFlags: [.command])
|
||||
}
|
||||
XCTAssertTrue(
|
||||
waitForSuggestionRowToBeSelected(gmailRow, timeout: 3.0),
|
||||
"Expected Cmd+N to select Gmail row \(gmailRowIndex). value=\(String(describing: gmailRow.value))"
|
||||
)
|
||||
}
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
|
|
@ -415,8 +466,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
|
|
@ -461,8 +511,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
|
|
@ -499,26 +548,28 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||
omnibar.typeText("go")
|
||||
omnibar.typeText("exam")
|
||||
|
||||
let typedPrefix = "exam"
|
||||
let inlineDeadline = Date().addingTimeInterval(3.0)
|
||||
var valueBeforeCmdA = ""
|
||||
while Date() < inlineDeadline {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.contains("google.com") {
|
||||
valueBeforeCmdA = (omnibar.value as? String) ?? ""
|
||||
let normalized = valueBeforeCmdA.lowercased()
|
||||
if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
XCTAssertTrue(
|
||||
((omnibar.value as? String) ?? "").contains("google.com"),
|
||||
"Expected inline completion to show google.com before Cmd+A."
|
||||
valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count,
|
||||
"Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)"
|
||||
)
|
||||
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
|
|
@ -526,11 +577,30 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
|
||||
let afterCmdA = (omnibar.value as? String) ?? ""
|
||||
XCTAssertTrue(
|
||||
afterCmdA.contains("google.com"),
|
||||
"Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. value=\(afterCmdA)"
|
||||
afterCmdA.lowercased().hasPrefix(typedPrefix) && afterCmdA.utf16.count > typedPrefix.utf16.count,
|
||||
"Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. before=\(valueBeforeCmdA) after=\(afterCmdA)"
|
||||
)
|
||||
}
|
||||
|
||||
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: timeout),
|
||||
"Expected app to launch in foreground. state=\(app.state.rawValue)"
|
||||
)
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private struct SeedEntry {
|
||||
let url: String
|
||||
let title: String
|
||||
|
|
@ -617,14 +687,81 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
add(attachment)
|
||||
}
|
||||
|
||||
private func waitForRowSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if ((row.value as? String) ?? "").contains("selected") {
|
||||
if isSuggestionRowSelected(row) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return ((row.value as? String) ?? "").contains("selected")
|
||||
return isSuggestionRowSelected(row)
|
||||
}
|
||||
|
||||
private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool {
|
||||
guard row.exists else { return false }
|
||||
guard let rawValue = row.value as? String else { return false }
|
||||
return rawValue.localizedCaseInsensitiveContains("selected")
|
||||
}
|
||||
|
||||
private func typeQueryAndWaitForSuggestions(
|
||||
app: XCUIApplication,
|
||||
omnibar: XCUIElement,
|
||||
query: String,
|
||||
timeout: TimeInterval,
|
||||
attempts: Int = 3
|
||||
) -> Bool {
|
||||
let suggestions = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
for _ in 0..<attempts {
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
_ = app.wait(for: .runningForeground, timeout: 2.0)
|
||||
}
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
guard omnibar.waitForExistence(timeout: 6.0) else { continue }
|
||||
omnibar.click()
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
omnibar.click()
|
||||
omnibar.typeText(query)
|
||||
if suggestions.waitForExistence(timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
}
|
||||
return suggestions.exists
|
||||
}
|
||||
|
||||
private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
guard omnibar.waitForExistence(timeout: 1.0) else { continue }
|
||||
|
||||
let before = (omnibar.value as? String) ?? ""
|
||||
omnibar.typeText("z")
|
||||
|
||||
let probeDeadline = Date().addingTimeInterval(0.5)
|
||||
var acceptedProbe = false
|
||||
while Date() < probeDeadline {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value != before {
|
||||
acceptedProbe = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
|
||||
if acceptedProbe {
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
return true
|
||||
}
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused"], timeout: 10.0),
|
||||
|
|
@ -93,10 +93,10 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_USE_GHOSTTY_CONFIG"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused", "ghosttyGotoSplitLeftShortcut"], timeout: 10.0),
|
||||
|
|
@ -109,7 +109,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
}
|
||||
|
||||
XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test")
|
||||
XCTAssertEqual(setup["ghosttyGotoSplitLeftShortcut"], "⌃⌘H", "Expected Ghostty config trigger to be Cmd+Ctrl+H")
|
||||
XCTAssertFalse((setup["ghosttyGotoSplitLeftShortcut"] ?? "").isEmpty, "Expected Ghostty trigger metadata to be present")
|
||||
|
||||
guard let expectedTerminalPaneId = setup["terminalPaneId"] else {
|
||||
XCTFail("Missing terminalPaneId in goto_split setup data")
|
||||
|
|
@ -132,8 +132,8 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0),
|
||||
|
|
@ -176,8 +176,8 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
|
||||
|
|
@ -225,8 +225,8 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
|
||||
|
|
@ -280,8 +280,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
||||
|
|
@ -314,8 +313,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
||||
|
|
@ -348,8 +346,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
||||
|
|
@ -390,8 +387,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launch()
|
||||
app.activate()
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
||||
|
|
@ -427,6 +423,25 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: timeout),
|
||||
"Expected app to launch in foreground. state=\(app.state.rawValue)"
|
||||
)
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
|
|
|
|||
|
|
@ -134,8 +134,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
}
|
||||
|
||||
let rightPanelId = ready["rightPanelId"] ?? ""
|
||||
XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be the focused panel before Ctrl+D. data=\(ready)")
|
||||
XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected AppKit first responder to match right split before Ctrl+D. data=\(ready)")
|
||||
guard !rightPanelId.isEmpty else {
|
||||
XCTFail("Missing rightPanelId in setup data. data=\(ready)")
|
||||
return
|
||||
}
|
||||
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Horizontal split")
|
||||
|
||||
// Exercise the real keyboard path (same path as user typing Ctrl+D), not an in-process helper.
|
||||
app.activate()
|
||||
|
|
@ -191,8 +194,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
}
|
||||
|
||||
let rightPanelId = ready["rightPanelId"] ?? ""
|
||||
XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be focused before Ctrl+D. data=\(ready)")
|
||||
XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected first responder to match right split before Ctrl+D. data=\(ready)")
|
||||
guard !rightPanelId.isEmpty else {
|
||||
XCTFail("Missing rightPanelId in setup data. data=\(ready)")
|
||||
return
|
||||
}
|
||||
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Three-pane layout")
|
||||
guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else {
|
||||
XCTFail("Timed out waiting for done=1 after Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])")
|
||||
return
|
||||
|
|
@ -257,16 +263,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
2,
|
||||
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ready["focusedPanelBefore"],
|
||||
exitPanelId,
|
||||
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ready["firstResponderPanelBefore"],
|
||||
exitPanelId,
|
||||
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
|
||||
)
|
||||
guard !exitPanelId.isEmpty else {
|
||||
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
|
||||
return
|
||||
}
|
||||
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close")
|
||||
|
||||
app.typeKey("d", modifierFlags: [.control])
|
||||
|
||||
|
|
@ -335,16 +336,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
2,
|
||||
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-bottom-close repro. data=\(ready)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ready["focusedPanelBefore"],
|
||||
exitPanelId,
|
||||
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ready["firstResponderPanelBefore"],
|
||||
exitPanelId,
|
||||
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
|
||||
)
|
||||
guard !exitPanelId.isEmpty else {
|
||||
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
|
||||
return
|
||||
}
|
||||
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-bottom-close")
|
||||
|
||||
app.typeKey("d", modifierFlags: [.control])
|
||||
|
||||
|
|
@ -412,16 +408,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
2,
|
||||
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ready["focusedPanelBefore"],
|
||||
exitPanelId,
|
||||
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ready["firstResponderPanelBefore"],
|
||||
exitPanelId,
|
||||
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
|
||||
)
|
||||
guard !exitPanelId.isEmpty else {
|
||||
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
|
||||
return
|
||||
}
|
||||
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close real key")
|
||||
|
||||
app.typeKey("d", modifierFlags: [.control])
|
||||
|
||||
|
|
@ -497,16 +488,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
2,
|
||||
"Attempt \(attempt): expected two panels before Ctrl+D in left/right repro. data=\(ready)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ready["focusedPanelBefore"],
|
||||
exitPanelId,
|
||||
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ready["firstResponderPanelBefore"],
|
||||
exitPanelId,
|
||||
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
|
||||
)
|
||||
guard !exitPanelId.isEmpty else {
|
||||
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
|
||||
return
|
||||
}
|
||||
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): left/right real key")
|
||||
|
||||
app.typeKey("d", modifierFlags: [.control])
|
||||
|
||||
|
|
@ -546,6 +532,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 {
|
||||
|
|
@ -619,6 +667,26 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
return nil
|
||||
}
|
||||
|
||||
private func assertCtrlDPreconditionsBeforeTrigger(
|
||||
_ data: [String: String],
|
||||
expectedExitPanelId: String,
|
||||
context: String
|
||||
) {
|
||||
XCTAssertEqual(
|
||||
data["focusedPanelBefore"],
|
||||
expectedExitPanelId,
|
||||
"\(context): expected target exit pane to be focused before Ctrl+D. data=\(data)"
|
||||
)
|
||||
let firstResponderPanelBefore = data["firstResponderPanelBefore"] ?? ""
|
||||
if !firstResponderPanelBefore.isEmpty {
|
||||
XCTAssertEqual(
|
||||
firstResponderPanelBefore,
|
||||
expectedExitPanelId,
|
||||
"\(context): expected first responder to match target pane before Ctrl+D when present. data=\(data)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadJSON(atPath path: String) -> [String: String]? {
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import CoreGraphics
|
|||
final class MultiWindowNotificationsUITests: XCTestCase {
|
||||
private var dataPath = ""
|
||||
private var socketPath = ""
|
||||
private var launchTag = ""
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
dataPath = "/tmp/cmux-ui-test-multi-window-notifs-\(UUID().uuidString).json"
|
||||
socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock"
|
||||
launchTag = "ui-tests-multi-window-notifs-\(UUID().uuidString.prefix(8))"
|
||||
try? FileManager.default.removeItem(atPath: dataPath)
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
}
|
||||
|
|
@ -25,8 +27,12 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||
app.launch()
|
||||
app.activate()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for multi-window routing test. state=\(app.state.rawValue)"
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: [
|
||||
|
|
@ -108,8 +114,12 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||
app.launch()
|
||||
app.activate()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for notifications popover shortcut test. state=\(app.state.rawValue)"
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["notifId1"], timeout: 15.0),
|
||||
|
|
@ -137,14 +147,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
XCTAssertTrue(waitForElementToDisappear(targetButton, timeout: 3.0), "Expected popover to close on Escape")
|
||||
}
|
||||
|
||||
func testEmptyNotificationsPopoverBlocksTerminalTyping() {
|
||||
func testEmptyNotificationsPopoverBlocksTerminalTyping() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-socketControlMode", "allowAll"]
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll"
|
||||
app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
|
||||
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||
app.launch()
|
||||
app.activate()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for empty popover blocking test. state=\(app.state.rawValue)"
|
||||
)
|
||||
|
||||
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0))
|
||||
XCTAssertTrue(waitForSocketPong(timeout: 8.0), "Expected control socket to respond")
|
||||
guard let resolvedPath = resolveSocketPath(timeout: 8.0) else {
|
||||
throw XCTSkip("Control socket unavailable in this test environment. requested=\(socketPath)")
|
||||
}
|
||||
socketPath = resolvedPath
|
||||
let pingResponse = waitForSocketPong(timeout: 8.0)
|
||||
guard pingResponse == "PONG" else {
|
||||
throw XCTSkip("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "<nil>")")
|
||||
}
|
||||
|
||||
_ = socketCommand("clear_notifications")
|
||||
|
||||
|
|
@ -198,6 +223,17 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return app.windows.count >= count
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let predicate = NSPredicate(format: "exists == false")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
||||
|
|
@ -238,31 +274,73 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return false
|
||||
}
|
||||
|
||||
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
|
||||
private func waitForSocketPong(timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastResponse: String?
|
||||
while Date() < deadline {
|
||||
if socketCommand("ping") == "PONG" {
|
||||
return true
|
||||
lastResponse = socketCommand("ping")
|
||||
if lastResponse == "PONG" {
|
||||
return "PONG"
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return socketCommand("ping") ?? lastResponse
|
||||
}
|
||||
|
||||
private func resolveSocketPath(timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
for candidate in expectedSocketCandidates() {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
if socketRespondsToPing(at: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
for candidate in expectedSocketCandidates() {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
if socketRespondsToPing(at: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func expectedSocketCandidates() -> [String] {
|
||||
var candidates = [socketPath]
|
||||
let taggedDebugSocket = "/tmp/cmux-debug-\(launchTag).sock"
|
||||
if taggedDebugSocket != socketPath {
|
||||
candidates.append(taggedDebugSocket)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private func socketRespondsToPing(at path: String) -> Bool {
|
||||
let originalPath = socketPath
|
||||
socketPath = path
|
||||
defer { socketPath = originalPath }
|
||||
return socketCommand("ping") == "PONG"
|
||||
}
|
||||
|
||||
private func socketCommand(_ cmd: String) -> String? {
|
||||
if let response = ControlSocketClient(path: socketPath).sendLine(cmd) {
|
||||
return response
|
||||
}
|
||||
return socketCommandViaNetcat(cmd)
|
||||
}
|
||||
|
||||
private func socketCommandViaNetcat(_ cmd: String) -> String? {
|
||||
let nc = "/usr/bin/nc"
|
||||
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: nc)
|
||||
proc.arguments = ["-U", socketPath, "-w", "2"]
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w 2 2>/dev/null"
|
||||
proc.arguments = ["-lc", script]
|
||||
|
||||
let inPipe = Pipe()
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
proc.standardInput = inPipe
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = errPipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
|
|
@ -270,11 +348,6 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return nil
|
||||
}
|
||||
|
||||
if let data = (cmd + "\n").data(using: .utf8) {
|
||||
inPipe.fileHandleForWriting.write(data)
|
||||
}
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
proc.waitUntilExit()
|
||||
|
||||
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
|
@ -286,6 +359,94 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func shellSingleQuote(_ value: String) -> String {
|
||||
if value.isEmpty { return "''" }
|
||||
return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
private final class ControlSocketClient {
|
||||
private let path: String
|
||||
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
func sendLine(_ line: String) -> String? {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return nil }
|
||||
defer { close(fd) }
|
||||
|
||||
#if os(macOS)
|
||||
var noSigPipe: Int32 = 1
|
||||
_ = withUnsafePointer(to: &noSigPipe) { ptr in
|
||||
setsockopt(
|
||||
fd,
|
||||
SOL_SOCKET,
|
||||
SO_NOSIGPIPE,
|
||||
ptr,
|
||||
socklen_t(MemoryLayout<Int32>.size)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
var addr = sockaddr_un()
|
||||
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
|
||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
let bytes = Array(path.utf8CString)
|
||||
guard bytes.count <= maxLen else { return nil }
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { p in
|
||||
let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self)
|
||||
memset(raw, 0, maxLen)
|
||||
for i in 0..<bytes.count {
|
||||
raw[i] = bytes[i]
|
||||
}
|
||||
}
|
||||
|
||||
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
|
||||
let addrLen = socklen_t(pathOffset + bytes.count)
|
||||
#if os(macOS)
|
||||
addr.sun_len = UInt8(min(Int(addrLen), 255))
|
||||
#endif
|
||||
|
||||
let connected = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, addrLen)
|
||||
}
|
||||
}
|
||||
guard connected == 0 else { return nil }
|
||||
|
||||
let payload = line + "\n"
|
||||
let wrote: Bool = payload.withCString { cstr in
|
||||
var remaining = strlen(cstr)
|
||||
var p = UnsafeRawPointer(cstr)
|
||||
while remaining > 0 {
|
||||
let n = write(fd, p, remaining)
|
||||
if n <= 0 { return false }
|
||||
remaining -= n
|
||||
p = p.advanced(by: n)
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard wrote else { return nil }
|
||||
|
||||
var buf = [UInt8](repeating: 0, count: 4096)
|
||||
var accum = ""
|
||||
while true {
|
||||
let n = read(fd, &buf, buf.count)
|
||||
if n <= 0 { break }
|
||||
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {
|
||||
accum.append(chunk)
|
||||
if let idx = accum.firstIndex(of: "\n") {
|
||||
return String(accum[..<idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
return accum.isEmpty ? nil : accum.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private func readCurrentTerminalText() -> String? {
|
||||
guard let response = socketCommand("read_terminal_text"), response.hasPrefix("OK ") else {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ final class SidebarResizeUITests: XCTestCase {
|
|||
let elements = app.descendants(matching: .any)
|
||||
let resizer = elements["SidebarResizer"]
|
||||
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
|
||||
XCTAssertTrue(waitForElementHittable(resizer, timeout: 5.0), "Expected sidebar resizer to become hittable")
|
||||
|
||||
let initialX = resizer.frame.minX
|
||||
|
||||
|
|
@ -35,4 +36,46 @@ 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))
|
||||
XCTAssertTrue(waitForElementHittable(resizer, timeout: 5.0), "Expected sidebar resizer to become hittable")
|
||||
|
||||
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||
let farRight = start.withOffset(CGVector(dx: max(1200, window.frame.width * 2.0), 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)"
|
||||
)
|
||||
}
|
||||
|
||||
private func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if element.exists, element.isHittable {
|
||||
let frame = element.frame
|
||||
if frame.width > 1, frame.height > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -274,7 +297,7 @@ final class TitlebarShortcutHintsUITests: XCTestCase {
|
|||
XCTAssertEqual(sidebarHintFrame.minY, notificationsHintFrame.minY, accuracy: 1.0)
|
||||
XCTAssertEqual(notificationsHintFrame.minY, newTabHintFrame.minY, accuracy: 1.0)
|
||||
// Keep the sidebar hint lane to the right of the sidebar icon so it cannot clip into the traffic-light backdrop.
|
||||
XCTAssertGreaterThanOrEqual(sidebarHintFrame.minX, hintedToggleFrame.minX - 1.0)
|
||||
XCTAssertGreaterThanOrEqual(sidebarHintFrame.minX, hintedToggleFrame.minX - 4.0)
|
||||
|
||||
let sortedHintFrames = [sidebarHintFrame, notificationsHintFrame, newTabHintFrame]
|
||||
.sorted { $0.minX < $1.minX }
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
docs/socket-focus-steal-audit.todo.md
Normal file
76
docs/socket-focus-steal-audit.todo.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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 <tag>
|
||||
# Usage: ./scripts/build-sign-upload.sh <tag> [--allow-overwrite]
|
||||
# Requires: source ~/.secrets/cmuxterm.env && export SPARKLE_PRIVATE_KEY
|
||||
|
||||
TAG="${1:?Usage: $0 <tag>}"
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./scripts/build-sign-upload.sh <tag> [--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"
|
||||
|
|
|
|||
37
scripts/release_asset_guard.js
Normal file
37
scripts/release_asset_guard.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
49
scripts/release_asset_guard.test.js
Normal file
49
scripts/release_asset_guard.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
|
|||
|
||||
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1"
|
||||
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
|
||||
RUN_TAG="tests-v1"
|
||||
|
||||
echo "== build =="
|
||||
# Work around stale explicit-module cache artifacts (notably Sentry headers) that can
|
||||
|
|
@ -51,7 +52,7 @@ launch_and_wait() {
|
|||
defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true
|
||||
|
||||
# Launch directly with UI test mode enabled so startup follows deterministic test codepaths.
|
||||
CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
|
||||
SOCK=""
|
||||
for _ in {1..120}; do
|
||||
|
|
@ -70,7 +71,7 @@ launch_and_wait() {
|
|||
export CMUX_SOCKET="$SOCK"
|
||||
|
||||
# Ensure LaunchServices has a visible/main window attached for rendering checks.
|
||||
open "$APP" >/dev/null 2>&1 || true
|
||||
CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
|
||||
echo "== wait ready =="
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
|
|||
|
||||
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2"
|
||||
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
|
||||
RUN_TAG="tests-v2"
|
||||
|
||||
echo "== build =="
|
||||
# Work around stale explicit-module cache artifacts (notably Sentry headers) that can
|
||||
|
|
@ -51,7 +52,7 @@ launch_and_wait() {
|
|||
defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true
|
||||
|
||||
# Launch directly with UI test mode enabled so startup follows deterministic test codepaths.
|
||||
CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
|
||||
SOCK=""
|
||||
for _ in {1..120}; do
|
||||
|
|
@ -70,7 +71,7 @@ launch_and_wait() {
|
|||
export CMUX_SOCKET="$SOCK"
|
||||
|
||||
# Ensure LaunchServices has a visible/main window attached for rendering checks.
|
||||
open "$APP" >/dev/null 2>&1 || true
|
||||
CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
|
||||
echo "== wait ready =="
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <last-tag>..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 <N> --repo manaflow-ai/cmux --json author --jq '.author.login'`. Also check linked issue reporters with `gh issue view <N> --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`.
|
||||
- Do not edit a separate docs changelog file; `web/app/docs/changelog/page.tsx` renders from `CHANGELOG.md`.
|
||||
- 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.
|
||||
|
|
|
|||
155
tests/cmux.py
155
tests/cmux.py
|
|
@ -500,7 +500,17 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None:
|
||||
def set_status(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
icon: str = None,
|
||||
color: str = None,
|
||||
url: str = None,
|
||||
priority: int = None,
|
||||
format: str = None,
|
||||
tab: str = None,
|
||||
) -> None:
|
||||
"""Set a sidebar status entry."""
|
||||
# Put options before `--` so value can contain arbitrary tokens like `--tab`.
|
||||
cmd = f"set_status {key}"
|
||||
|
|
@ -508,6 +518,12 @@ class cmux:
|
|||
cmd += f" --icon={icon}"
|
||||
if color:
|
||||
cmd += f" --color={color}"
|
||||
if url:
|
||||
cmd += f" --url={_quote_option_value(url)}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if format:
|
||||
cmd += f" --format={format}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(value)}"
|
||||
|
|
@ -524,6 +540,86 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_meta(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
icon: str = None,
|
||||
color: str = None,
|
||||
url: str = None,
|
||||
priority: int = None,
|
||||
format: str = None,
|
||||
tab: str = None,
|
||||
) -> None:
|
||||
"""Report a sidebar metadata entry."""
|
||||
cmd = f"report_meta {key}"
|
||||
if icon:
|
||||
cmd += f" --icon={icon}"
|
||||
if color:
|
||||
cmd += f" --color={color}"
|
||||
if url:
|
||||
cmd += f" --url={_quote_option_value(url)}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if format:
|
||||
cmd += f" --format={format}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(value)}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_meta(self, key: str, tab: str = None) -> None:
|
||||
"""Remove a sidebar metadata entry."""
|
||||
cmd = f"clear_meta {key}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def list_meta(self, tab: str = None) -> str:
|
||||
"""List sidebar metadata entries."""
|
||||
cmd = "list_meta"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if response.startswith("ERROR"):
|
||||
raise cmuxError(response)
|
||||
return response
|
||||
|
||||
def report_meta_block(self, key: str, markdown: str, priority: int = None, tab: str = None) -> None:
|
||||
"""Report a freeform sidebar markdown metadata block."""
|
||||
cmd = f"report_meta_block {key}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(markdown)}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_meta_block(self, key: str, tab: str = None) -> None:
|
||||
"""Remove a sidebar markdown metadata block."""
|
||||
cmd = f"clear_meta_block {key}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def list_meta_blocks(self, tab: str = None) -> str:
|
||||
"""List sidebar markdown metadata blocks."""
|
||||
cmd = "list_meta_blocks"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if response.startswith("ERROR"):
|
||||
raise cmuxError(response)
|
||||
return response
|
||||
|
||||
def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None:
|
||||
"""Append a sidebar log entry."""
|
||||
# TerminalController.parseOptions treats any --* token as an option until
|
||||
|
|
@ -572,6 +668,63 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_pr(
|
||||
self,
|
||||
number: int,
|
||||
url: str,
|
||||
label: str = None,
|
||||
state: str = None,
|
||||
tab: str = None,
|
||||
panel: str = None,
|
||||
) -> None:
|
||||
"""Report pull-request metadata for sidebar display."""
|
||||
cmd = f"report_pr {number} {url}"
|
||||
if label:
|
||||
cmd += f" --label={_quote_option_value(label)}"
|
||||
if state:
|
||||
cmd += f" --state={state}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_review(
|
||||
self,
|
||||
number: int,
|
||||
url: str,
|
||||
label: str = None,
|
||||
state: str = None,
|
||||
tab: str = None,
|
||||
panel: str = None,
|
||||
) -> None:
|
||||
"""Report provider-specific review metadata (GitLab MR, Bitbucket PR, etc.)."""
|
||||
cmd = f"report_review {number} {url}"
|
||||
if label:
|
||||
cmd += f" --label={_quote_option_value(label)}"
|
||||
if state:
|
||||
cmd += f" --state={state}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_pr(self, tab: str = None, panel: str = None) -> None:
|
||||
"""Clear pull-request metadata for sidebar display."""
|
||||
cmd = "clear_pr"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_ports(self, *ports: int, tab: str = None) -> None:
|
||||
"""Report listening ports for sidebar display."""
|
||||
port_str = " ".join(str(p) for p in ports)
|
||||
|
|
|
|||
126
tests/test_browser_chrome_contrast_regression.py
Normal file
126
tests/test_browser_chrome_contrast_regression.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guards for browser chrome contrast in mixed theme setups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
source = view_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
try:
|
||||
browser_panel_view_block = extract_block(source, "struct BrowserPanelView: View")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
browser_panel_view_block = ""
|
||||
|
||||
try:
|
||||
resolver_block = extract_block(source, "func resolvedBrowserChromeColorScheme(")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
resolver_block = ""
|
||||
|
||||
if resolver_block:
|
||||
if "backgroundColor.isLightColor ? .light : .dark" not in resolver_block:
|
||||
failures.append(
|
||||
"resolvedBrowserChromeColorScheme must map luminance to a light/dark ColorScheme"
|
||||
)
|
||||
|
||||
try:
|
||||
chrome_scheme_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var browserChromeColorScheme: ColorScheme",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
chrome_scheme_block = ""
|
||||
|
||||
if chrome_scheme_block and "resolvedBrowserChromeColorScheme(" not in chrome_scheme_block:
|
||||
failures.append("browserChromeColorScheme must use resolvedBrowserChromeColorScheme")
|
||||
|
||||
try:
|
||||
omnibar_background_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var omnibarPillBackgroundColor: NSColor",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
omnibar_background_block = ""
|
||||
|
||||
if omnibar_background_block and "for: browserChromeColorScheme" not in omnibar_background_block:
|
||||
failures.append("omnibar pill background must use browserChromeColorScheme")
|
||||
|
||||
try:
|
||||
address_bar_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var addressBar: some View",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
address_bar_block = ""
|
||||
|
||||
if address_bar_block and ".environment(\\.colorScheme, browserChromeColorScheme)" not in address_bar_block:
|
||||
failures.append("addressBar must apply browserChromeColorScheme via environment")
|
||||
|
||||
try:
|
||||
body_block = extract_block(browser_panel_view_block, "var body: some View")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
body_block = ""
|
||||
|
||||
if body_block:
|
||||
if "OmnibarSuggestionsView(" not in body_block:
|
||||
failures.append("Expected OmnibarSuggestionsView block in BrowserPanelView body")
|
||||
elif ".environment(\\.colorScheme, browserChromeColorScheme)" not in body_block:
|
||||
failures.append("Omnibar suggestions must apply browserChromeColorScheme via environment")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser chrome contrast regression guards failed")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser chrome contrast regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
86
tests/test_browser_console_errors_cli_output_regression.py
Normal file
86
tests/test_browser_console_errors_cli_output_regression.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guard for browser console/errors CLI output formatting.
|
||||
|
||||
Ensures non-JSON `browser console list` and `browser errors list` do not fall
|
||||
back to unconditional `OK` when logs exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
cli_path = root / "CLI" / "cmux.swift"
|
||||
cli_source = cli_path.read_text(encoding="utf-8")
|
||||
browser_block = extract_block(cli_source, "private func runBrowserCommand(")
|
||||
|
||||
if "func displayBrowserLogItems(_ value: Any?) -> String?" not in browser_block:
|
||||
failures.append("runBrowserCommand() is missing displayBrowserLogItems() helper")
|
||||
else:
|
||||
helper_block = extract_block(browser_block, "func displayBrowserLogItems(_ value: Any?) -> String?")
|
||||
if "return \"[\\(level)] \\(text)\"" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer renders level-prefixed log lines")
|
||||
if "return \"[error] \\(message)\"" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer renders concise JS error messages")
|
||||
if "return displayBrowserValue(dict)" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer falls back to structured formatting")
|
||||
|
||||
console_block = extract_block(browser_block, 'if subcommand == "console"')
|
||||
if 'displayBrowserLogItems(payload["entries"])' not in console_block:
|
||||
failures.append("browser console path no longer formats entries for non-JSON output")
|
||||
if 'output(payload, fallback: "OK")' in console_block:
|
||||
failures.append("browser console path regressed to unconditional OK output")
|
||||
|
||||
errors_block = extract_block(browser_block, 'if subcommand == "errors"')
|
||||
if 'displayBrowserLogItems(payload["errors"])' not in errors_block:
|
||||
failures.append("browser errors path no longer formats errors for non-JSON output")
|
||||
if 'output(payload, fallback: "OK")' in errors_block:
|
||||
failures.append("browser errors path regressed to unconditional OK output")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser console/errors CLI output regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser console/errors CLI output regression guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
128
tests/test_browser_eval_async_wrapper_regression.py
Normal file
128
tests/test_browser_eval_async_wrapper_regression.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guard for browser eval async wrapping + telemetry injection."""
|
||||
|
||||
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 extract_span(source: str, start_marker: str, end_marker: str) -> str:
|
||||
start = source.find(start_marker)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing start marker: {start_marker}")
|
||||
end = source.find(end_marker, start)
|
||||
if end < 0:
|
||||
raise ValueError(f"Missing end marker: {end_marker}")
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
terminal_path = root / "Sources" / "TerminalController.swift"
|
||||
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
|
||||
terminal_source = terminal_path.read_text(encoding="utf-8")
|
||||
panel_source = panel_path.read_text(encoding="utf-8")
|
||||
|
||||
if "preferAsync: Bool = false" not in terminal_source:
|
||||
failures.append("v2RunJavaScript() no longer exposes preferAsync toggle")
|
||||
run_js_block = extract_block(terminal_source, "private func v2RunJavaScript(")
|
||||
if "callAsyncJavaScript" not in run_js_block:
|
||||
failures.append("v2RunJavaScript() no longer uses callAsyncJavaScript for async JS")
|
||||
|
||||
run_browser_js_block = extract_block(terminal_source, "private func v2RunBrowserJavaScript(")
|
||||
required_wrapper_tokens = [
|
||||
"let asyncFunctionBody =",
|
||||
"__cmuxMaybeAwait",
|
||||
"__cmux_t",
|
||||
"__cmux_v",
|
||||
"return await __cmuxEvalInFrame();",
|
||||
"preferAsync: true",
|
||||
]
|
||||
for token in required_wrapper_tokens:
|
||||
if token not in run_browser_js_block:
|
||||
failures.append(f"v2RunBrowserJavaScript() missing async eval wrapper token: {token}")
|
||||
|
||||
if "v2BrowserUndefinedSentinel" not in terminal_source:
|
||||
failures.append("TerminalController is missing undefined sentinel handling")
|
||||
if "v2BrowserEvalEnvelopeTypeUndefined" not in terminal_source:
|
||||
failures.append("TerminalController is missing undefined envelope decode constant")
|
||||
|
||||
hook_block = extract_block(terminal_source, "private func v2BrowserEnsureTelemetryHooks(")
|
||||
if "BrowserPanel.telemetryHookBootstrapScriptSource" not in hook_block:
|
||||
failures.append("v2BrowserEnsureTelemetryHooks() no longer uses shared BrowserPanel telemetry source")
|
||||
|
||||
if "static let telemetryHookBootstrapScriptSource" not in panel_source:
|
||||
failures.append("BrowserPanel is missing telemetryHookBootstrapScriptSource")
|
||||
if "static let dialogTelemetryHookBootstrapScriptSource" not in panel_source:
|
||||
failures.append("BrowserPanel is missing dialogTelemetryHookBootstrapScriptSource")
|
||||
|
||||
base_script_span = extract_span(
|
||||
panel_source,
|
||||
"static let telemetryHookBootstrapScriptSource =",
|
||||
"static let dialogTelemetryHookBootstrapScriptSource =",
|
||||
)
|
||||
if "window.alert = function(message)" in base_script_span:
|
||||
failures.append("Document-start telemetry script should not override alert dialogs")
|
||||
if "window.confirm = function(message)" in base_script_span:
|
||||
failures.append("Document-start telemetry script should not override confirm dialogs")
|
||||
if "window.prompt = function(message, defaultValue)" in base_script_span:
|
||||
failures.append("Document-start telemetry script should not override prompt dialogs")
|
||||
|
||||
panel_init_block = extract_block(
|
||||
panel_source,
|
||||
"init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil)",
|
||||
)
|
||||
required_init_tokens = [
|
||||
"config.userContentController.addUserScript(",
|
||||
"source: Self.telemetryHookBootstrapScriptSource",
|
||||
"injectionTime: .atDocumentStart",
|
||||
]
|
||||
for token in required_init_tokens:
|
||||
if token not in panel_init_block:
|
||||
failures.append(f"BrowserPanel init() missing telemetry user-script token: {token}")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser eval async wrapper / telemetry injection regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser eval async wrapper / telemetry injection regression guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
89
tests/test_browser_eval_cli_output_regression.py
Normal file
89
tests/test_browser_eval_cli_output_regression.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guard for browser eval CLI output formatting.
|
||||
|
||||
Ensures `cmux browser <surface> eval <script>` prints the evaluated value
|
||||
instead of always printing `OK`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
cli_path = root / "CLI" / "cmux.swift"
|
||||
cli_source = cli_path.read_text(encoding="utf-8")
|
||||
browser_block = extract_block(cli_source, "private func runBrowserCommand(")
|
||||
|
||||
if "func displayBrowserValue(_ value: Any) -> String" not in browser_block:
|
||||
failures.append("runBrowserCommand() is missing displayBrowserValue() helper")
|
||||
else:
|
||||
value_block = extract_block(browser_block, "func displayBrowserValue(_ value: Any) -> String")
|
||||
if 'dict["__cmux_t"] as? String' not in value_block or 'type == "undefined"' not in value_block:
|
||||
failures.append("displayBrowserValue() no longer maps __cmux_t=undefined to literal 'undefined'")
|
||||
required_guards = [
|
||||
"if value is NSNull",
|
||||
"if let string = value as? String",
|
||||
"if let bool = value as? Bool",
|
||||
"if let number = value as? NSNumber",
|
||||
]
|
||||
for guard in required_guards:
|
||||
if guard not in value_block:
|
||||
failures.append(f"displayBrowserValue() no longer handles: {guard}")
|
||||
|
||||
eval_block = extract_block(browser_block, 'if subcommand == "eval"')
|
||||
if 'let payload = try client.sendV2(method: "browser.eval"' not in eval_block:
|
||||
failures.append("browser eval path no longer calls browser.eval v2 method")
|
||||
if 'if let value = payload["value"]' not in eval_block:
|
||||
failures.append("browser eval path no longer reads payload value")
|
||||
if "fallback = displayBrowserValue(value)" not in eval_block:
|
||||
failures.append("browser eval path no longer formats payload value for CLI output")
|
||||
if 'output(payload, fallback: "OK")' in eval_block:
|
||||
failures.append("browser eval path regressed to unconditional OK output")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser eval CLI output regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser eval CLI output regression guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
388
tests/test_browser_new_tab_surface_focus_omnibar.py
Normal file
388
tests/test_browser_new_tab_surface_focus_omnibar.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test:
|
||||
1. Focusing a blank browser surface should focus the omnibar.
|
||||
2. Focusing a pane that contains a blank browser should focus the omnibar.
|
||||
3. If command palette is open, focusing that blank browser surface must not steal input.
|
||||
4. Cmd+P switcher focusing an existing blank browser surface should focus the omnibar.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
def v2_call(client: cmux, method: str, params: dict[str, Any] | None = None, request_id: str = "1") -> dict[str, Any]:
|
||||
payload = {
|
||||
"id": request_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
raw = client._send_command(json.dumps(payload))
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise cmuxError(f"Invalid v2 JSON response for {method}: {raw}") from exc
|
||||
|
||||
if not parsed.get("ok"):
|
||||
raise cmuxError(f"v2 {method} failed: {parsed.get('error')}")
|
||||
|
||||
result = parsed.get("result")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def wait_for(predicate, timeout_s: float, interval_s: float = 0.1) -> bool:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval_s)
|
||||
return False
|
||||
|
||||
|
||||
def browser_address_bar_focus_state(client: cmux, surface_id: str | None = None, request_id: str = "browser-focus") -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if surface_id:
|
||||
params["surface_id"] = surface_id
|
||||
return v2_call(client, "debug.browser.address_bar_focused", params, request_id=request_id)
|
||||
|
||||
|
||||
def set_command_palette_visible(client: cmux, window_id: str, target_visible: bool) -> bool:
|
||||
for idx in range(5):
|
||||
state = v2_call(
|
||||
client,
|
||||
"debug.command_palette.visible",
|
||||
{"window_id": window_id},
|
||||
request_id=f"palette-visible-{idx}",
|
||||
)
|
||||
is_visible = bool(state.get("visible"))
|
||||
if is_visible == target_visible:
|
||||
return True
|
||||
v2_call(
|
||||
client,
|
||||
"debug.command_palette.toggle",
|
||||
{"window_id": window_id},
|
||||
request_id=f"palette-toggle-{idx}",
|
||||
)
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
|
||||
def command_palette_results(client: cmux, window_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
payload = v2_call(
|
||||
client,
|
||||
"debug.command_palette.results",
|
||||
{"window_id": window_id, "limit": limit},
|
||||
request_id="palette-results"
|
||||
)
|
||||
rows = payload.get("results")
|
||||
if isinstance(rows, list):
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def command_palette_selected_index(client: cmux, window_id: str) -> int:
|
||||
payload = v2_call(
|
||||
client,
|
||||
"debug.command_palette.selection",
|
||||
{"window_id": window_id},
|
||||
request_id="palette-selection"
|
||||
)
|
||||
selected_index = payload.get("selected_index")
|
||||
if isinstance(selected_index, int):
|
||||
return max(0, selected_index)
|
||||
return 0
|
||||
|
||||
|
||||
def move_command_palette_selection_to_index(client: cmux, window_id: str, target_index: int) -> bool:
|
||||
target = max(0, target_index)
|
||||
for _ in range(40):
|
||||
current = command_palette_selected_index(client, window_id)
|
||||
if current == target:
|
||||
return True
|
||||
if current < target:
|
||||
client.simulate_shortcut("down")
|
||||
else:
|
||||
client.simulate_shortcut("up")
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
def current_window_id(client: cmux) -> str:
|
||||
window_current = v2_call(client, "window.current", request_id="window-current")
|
||||
window_id = window_current.get("window_id")
|
||||
if not isinstance(window_id, str) or not window_id:
|
||||
raise cmuxError(f"Invalid window.current payload: {window_current}")
|
||||
return window_id
|
||||
|
||||
|
||||
def main() -> int:
|
||||
client = cmux()
|
||||
workspace_ids: list[str] = []
|
||||
window_id: str | None = None
|
||||
|
||||
try:
|
||||
client.connect()
|
||||
client.activate_app()
|
||||
|
||||
# Scenario 1: focus_surface on a blank browser should focus omnibar.
|
||||
workspace_id = client.new_workspace()
|
||||
workspace_ids.append(workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.4)
|
||||
window_id = current_window_id(client)
|
||||
if not set_command_palette_visible(client, window_id, False):
|
||||
raise cmuxError("Failed to ensure command palette is hidden for scenario 1")
|
||||
|
||||
browser_id = client.new_surface(panel_type="browser")
|
||||
time.sleep(0.3)
|
||||
|
||||
surfaces = client.list_surfaces()
|
||||
terminal_id = next((surface_id for _, surface_id, _ in surfaces if surface_id != browser_id), None)
|
||||
if not terminal_id:
|
||||
raise cmuxError("Missing terminal surface for focus setup")
|
||||
|
||||
client.focus_surface_by_panel(terminal_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
# Primary behavior: focusing a blank browser tab should focus the omnibar.
|
||||
client.focus_surface_by_panel(browser_id)
|
||||
did_focus_address_bar = wait_for(
|
||||
lambda: bool(
|
||||
browser_address_bar_focus_state(
|
||||
client,
|
||||
surface_id=browser_id,
|
||||
request_id="browser-focus-primary"
|
||||
).get("focused")
|
||||
),
|
||||
timeout_s=3.0,
|
||||
interval_s=0.1
|
||||
)
|
||||
if not did_focus_address_bar:
|
||||
raise cmuxError("Blank browser surface did not focus omnibar after focus_surface")
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_ids.remove(workspace_id)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Scenario 2: focusing a pane that contains a blank browser should focus omnibar.
|
||||
workspace_id = client.new_workspace()
|
||||
workspace_ids.append(workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.4)
|
||||
window_id = current_window_id(client)
|
||||
if not set_command_palette_visible(client, window_id, False):
|
||||
raise cmuxError("Failed to ensure command palette is hidden for scenario 2")
|
||||
|
||||
initial_surfaces = client.list_surfaces()
|
||||
left_terminal_id = next((surface_id for _, surface_id, _ in initial_surfaces), None)
|
||||
if not left_terminal_id:
|
||||
raise cmuxError("Missing initial terminal surface for split setup")
|
||||
|
||||
split_browser_id = client.new_pane(direction="right", panel_type="browser")
|
||||
time.sleep(0.3)
|
||||
|
||||
pane_rows = client.list_panes()
|
||||
left_pane: str | None = None
|
||||
browser_pane: str | None = None
|
||||
for _, pane_id, _, _ in pane_rows:
|
||||
pane_surface_ids = {surface_id for _, surface_id, _, _ in client.list_pane_surfaces(pane_id)}
|
||||
if left_terminal_id in pane_surface_ids:
|
||||
left_pane = pane_id
|
||||
if split_browser_id in pane_surface_ids:
|
||||
browser_pane = pane_id
|
||||
|
||||
if not left_pane or not browser_pane:
|
||||
raise cmuxError("Failed to locate split panes for pane-focus scenario")
|
||||
|
||||
client.focus_pane(left_pane)
|
||||
time.sleep(0.2)
|
||||
client.focus_pane(browser_pane)
|
||||
|
||||
did_focus_split_browser = wait_for(
|
||||
lambda: bool(
|
||||
browser_address_bar_focus_state(
|
||||
client,
|
||||
surface_id=split_browser_id,
|
||||
request_id="browser-focus-pane"
|
||||
).get("focused")
|
||||
),
|
||||
timeout_s=3.0,
|
||||
interval_s=0.1
|
||||
)
|
||||
if not did_focus_split_browser:
|
||||
raise cmuxError("Blank browser pane did not focus omnibar after focus_pane")
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_ids.remove(workspace_id)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Scenario 3: command palette should keep input focus when switching to a blank browser surface.
|
||||
workspace_id = client.new_workspace()
|
||||
workspace_ids.append(workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.4)
|
||||
window_id = current_window_id(client)
|
||||
if not set_command_palette_visible(client, window_id, False):
|
||||
raise cmuxError("Failed to reset command palette before scenario 3")
|
||||
|
||||
blank_browser_id = client.new_surface(panel_type="browser")
|
||||
time.sleep(0.3)
|
||||
|
||||
surfaces = client.list_surfaces()
|
||||
terminal_id = next((surface_id for _, surface_id, _ in surfaces if surface_id != blank_browser_id), None)
|
||||
if not terminal_id:
|
||||
raise cmuxError("Missing terminal surface for command palette scenario")
|
||||
|
||||
client.focus_surface_by_panel(terminal_id)
|
||||
wait_for(
|
||||
lambda: not bool(
|
||||
browser_address_bar_focus_state(
|
||||
client,
|
||||
request_id="browser-focus-cleared"
|
||||
).get("focused")
|
||||
),
|
||||
timeout_s=2.0,
|
||||
interval_s=0.1
|
||||
)
|
||||
|
||||
if not set_command_palette_visible(client, window_id, True):
|
||||
raise cmuxError("Failed to open command palette")
|
||||
|
||||
client.focus_surface_by_panel(blank_browser_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
palette_visible_after_focus = bool(
|
||||
v2_call(
|
||||
client,
|
||||
"debug.command_palette.visible",
|
||||
{"window_id": window_id},
|
||||
request_id="palette-visible-after-focus"
|
||||
).get("visible")
|
||||
)
|
||||
if not palette_visible_after_focus:
|
||||
raise cmuxError("Command palette closed unexpectedly after focus_surface")
|
||||
|
||||
blank_focus_state = browser_address_bar_focus_state(
|
||||
client,
|
||||
surface_id=blank_browser_id,
|
||||
request_id="browser-focus-palette"
|
||||
)
|
||||
if bool(blank_focus_state.get("focused")):
|
||||
raise cmuxError("Blank browser tab stole omnibar focus while command palette was visible")
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_ids.remove(workspace_id)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Scenario 4: Cmd+P switcher selecting an existing blank browser surface should focus omnibar.
|
||||
workspace_id = client.new_workspace()
|
||||
workspace_ids.append(workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.4)
|
||||
window_id = current_window_id(client)
|
||||
if not set_command_palette_visible(client, window_id, False):
|
||||
raise cmuxError("Failed to reset command palette before scenario 4")
|
||||
|
||||
switcher_browser_id = client.new_surface(panel_type="browser")
|
||||
time.sleep(0.3)
|
||||
|
||||
switcher_surfaces = client.list_surfaces()
|
||||
switcher_terminal_id = next((surface_id for _, surface_id, _ in switcher_surfaces if surface_id != switcher_browser_id), None)
|
||||
if not switcher_terminal_id:
|
||||
raise cmuxError("Missing terminal surface for Cmd+P switcher scenario")
|
||||
|
||||
client.focus_surface_by_panel(switcher_terminal_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.simulate_shortcut("cmd+p")
|
||||
if not wait_for(
|
||||
lambda: bool(
|
||||
v2_call(
|
||||
client,
|
||||
"debug.command_palette.visible",
|
||||
{"window_id": window_id},
|
||||
request_id="palette-visible-switcher-open"
|
||||
).get("visible")
|
||||
),
|
||||
timeout_s=2.0,
|
||||
interval_s=0.1
|
||||
):
|
||||
raise cmuxError("Cmd+P did not open command palette switcher")
|
||||
|
||||
client.simulate_type("new tab")
|
||||
time.sleep(0.2)
|
||||
|
||||
target_command_id = f"switcher.surface.{workspace_id.lower()}.{switcher_browser_id.lower()}"
|
||||
switcher_results = command_palette_results(client, window_id, limit=50)
|
||||
target_index = next(
|
||||
(
|
||||
idx for idx, row in enumerate(switcher_results)
|
||||
if isinstance(row.get("command_id"), str) and row.get("command_id") == target_command_id
|
||||
),
|
||||
None
|
||||
)
|
||||
if target_index is None:
|
||||
raise cmuxError(f"Cmd+P switcher did not list target surface command {target_command_id}")
|
||||
|
||||
if not move_command_palette_selection_to_index(client, window_id, target_index):
|
||||
raise cmuxError(f"Failed to move Cmd+P selection to result index {target_index}")
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
|
||||
did_focus_switcher_target = wait_for(
|
||||
lambda: (
|
||||
not bool(
|
||||
v2_call(
|
||||
client,
|
||||
"debug.command_palette.visible",
|
||||
{"window_id": window_id},
|
||||
request_id="palette-visible-switcher-after-enter"
|
||||
).get("visible")
|
||||
)
|
||||
and bool(
|
||||
browser_address_bar_focus_state(
|
||||
client,
|
||||
surface_id=switcher_browser_id,
|
||||
request_id="browser-focus-switcher"
|
||||
).get("focused")
|
||||
)
|
||||
),
|
||||
timeout_s=3.0,
|
||||
interval_s=0.1
|
||||
)
|
||||
if not did_focus_switcher_target:
|
||||
raise cmuxError("Cmd+P switcher focus to blank browser did not focus omnibar")
|
||||
|
||||
print("PASS: blank-browser focus paths (surface, pane, and Cmd+P switcher) drive omnibar, while command palette visibility blocks focus stealing")
|
||||
return 0
|
||||
|
||||
except cmuxError as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
finally:
|
||||
if window_id:
|
||||
try:
|
||||
_ = set_command_palette_visible(client, window_id, False)
|
||||
except Exception:
|
||||
pass
|
||||
for workspace_id in list(workspace_ids):
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
125
tests/test_browser_omnibar_compact_layout_regression.py
Normal file
125
tests/test_browser_omnibar_compact_layout_regression.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guards for compact browser omnibar sizing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
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 parse_cgfloat_constant(source: str, name: str) -> float | None:
|
||||
match = re.search(
|
||||
rf"private let {re.escape(name)}: CGFloat = ([0-9]+(?:\.[0-9]+)?)",
|
||||
source,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
view_source = view_path.read_text(encoding="utf-8")
|
||||
|
||||
hit_size = parse_cgfloat_constant(view_source, "addressBarButtonHitSize")
|
||||
if hit_size is None:
|
||||
failures.append("addressBarButtonHitSize constant is missing")
|
||||
elif hit_size > 26:
|
||||
failures.append(
|
||||
f"addressBarButtonHitSize regressed to {hit_size:g}; expected <= 26 for compact omnibar height"
|
||||
)
|
||||
|
||||
vertical_padding = parse_cgfloat_constant(view_source, "addressBarVerticalPadding")
|
||||
if vertical_padding is None:
|
||||
failures.append("addressBarVerticalPadding constant is missing")
|
||||
elif vertical_padding > 4:
|
||||
failures.append(
|
||||
f"addressBarVerticalPadding regressed to {vertical_padding:g}; expected <= 4 for compact omnibar height"
|
||||
)
|
||||
|
||||
omnibar_corner_radius = parse_cgfloat_constant(view_source, "omnibarPillCornerRadius")
|
||||
if omnibar_corner_radius is None:
|
||||
failures.append("omnibarPillCornerRadius constant is missing")
|
||||
elif omnibar_corner_radius > 10:
|
||||
failures.append(
|
||||
f"omnibarPillCornerRadius regressed to {omnibar_corner_radius:g}; expected <= 10 to keep a squircle profile"
|
||||
)
|
||||
|
||||
address_bar_block = extract_block(view_source, "private var addressBar: some View")
|
||||
if ".padding(.vertical, addressBarVerticalPadding)" not in address_bar_block:
|
||||
failures.append("addressBar no longer applies compact vertical padding via addressBarVerticalPadding")
|
||||
|
||||
omnibar_field_block = extract_block(view_source, "private var omnibarField: some View")
|
||||
if omnibar_field_block.count(
|
||||
"RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)"
|
||||
) < 2:
|
||||
failures.append(
|
||||
"omnibarField no longer uses continuous rounded-rectangle background+stroke tied to omnibarPillCornerRadius"
|
||||
)
|
||||
|
||||
button_bar_block = extract_block(view_source, "private var addressBarButtonBar: some View")
|
||||
hit_frame_uses = button_bar_block.count("addressBarButtonHitSize")
|
||||
if hit_frame_uses < 3:
|
||||
failures.append(
|
||||
"navigation buttons no longer consistently use addressBarButtonHitSize frames (padding may be lost)"
|
||||
)
|
||||
|
||||
extract_block(view_source, "private struct OmnibarAddressButtonStyle: ButtonStyle")
|
||||
style_body_block = extract_block(view_source, "private struct OmnibarAddressButtonStyleBody: View")
|
||||
if "configuration.isPressed" not in style_body_block:
|
||||
failures.append("OmnibarAddressButtonStyleBody is missing pressed-state styling")
|
||||
if "isHovered" not in style_body_block or ".onHover" not in style_body_block:
|
||||
failures.append("OmnibarAddressButtonStyleBody is missing hover-state styling")
|
||||
|
||||
style_uses = view_source.count(".buttonStyle(OmnibarAddressButtonStyle())")
|
||||
if style_uses < 4:
|
||||
failures.append(
|
||||
"address bar buttons no longer consistently use OmnibarAddressButtonStyle"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser omnibar compact layout regression guards failed")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser omnibar compact layout regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
25
tests/test_ci_create_dmg_pinned.sh
Executable file
25
tests/test_ci_create_dmg_pinned.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test for https://github.com/manaflow-ai/cmux/issues/387.
|
||||
# Ensures release workflows pin create-dmg to an explicit version.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
WORKFLOWS=(
|
||||
"$ROOT_DIR/.github/workflows/release.yml"
|
||||
"$ROOT_DIR/.github/workflows/nightly.yml"
|
||||
)
|
||||
|
||||
for workflow in "${WORKFLOWS[@]}"; do
|
||||
if ! grep -Eq 'npm install --global .*create-dmg@' "$workflow"; then
|
||||
echo "FAIL: $workflow must install create-dmg with an explicit version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq 'npm install --global[[:space:]]+create-dmg([[:space:]]|$)' "$workflow"; then
|
||||
echo "FAIL: $workflow still has unpinned create-dmg install"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "PASS: create-dmg install is pinned in release workflows"
|
||||
16
tests/test_ci_scheme_testaction_debug.sh
Executable file
16
tests/test_ci_scheme_testaction_debug.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCHEME_FILE="GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme"
|
||||
|
||||
if [ ! -f "$SCHEME_FILE" ]; then
|
||||
echo "FAIL: Missing scheme file at $SCHEME_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q '<TestAction buildConfiguration="Debug"' "$SCHEME_FILE"; then
|
||||
echo "FAIL: cmux scheme TestAction must use Debug build configuration for UI test setup hooks" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: cmux scheme TestAction uses Debug"
|
||||
29
tests/test_ci_self_hosted_guard.sh
Executable file
29
tests/test_ci_self_hosted_guard.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test for https://github.com/manaflow-ai/cmux/issues/385.
|
||||
# Ensures self-hosted UI tests are never run for fork pull requests.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml"
|
||||
|
||||
EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository"
|
||||
|
||||
if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then
|
||||
echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE"
|
||||
echo "Expected line:"
|
||||
echo " $EXPECTED_IF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ tests:/ { in_tests=1; next }
|
||||
in_tests && /^ [^[:space:]]/ { in_tests=0 }
|
||||
in_tests && /runs-on: self-hosted/ { saw_self_hosted=1 }
|
||||
in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 }
|
||||
END { exit !(saw_self_hosted && saw_guard) }
|
||||
' "$WORKFLOW_FILE"; then
|
||||
echo "FAIL: tests block must keep both self-hosted and fork guard"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: tests self-hosted fork guard is present"
|
||||
22
tests/test_ci_unit_test_spm_retry.sh
Executable file
22
tests/test_ci_unit_test_spm_retry.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test for CI unit-test SwiftPM dependency flake handling.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml"
|
||||
|
||||
REQUIRED_PATTERNS=(
|
||||
"run_unit_tests()"
|
||||
"Could not resolve package dependencies"
|
||||
"rm -rf ~/Library/Caches/org.swift.swiftpm"
|
||||
"OUTPUT=\$(run_unit_tests)"
|
||||
)
|
||||
|
||||
for pattern in "${REQUIRED_PATTERNS[@]}"; do
|
||||
if ! grep -Fq "$pattern" "$WORKFLOW_FILE"; then
|
||||
echo "FAIL: Missing pattern in ci.yml: $pattern"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "PASS: CI unit-test SwiftPM retry guard is present"
|
||||
84
tests/test_claude_hook_missing_socket_error.py
Normal file
84
tests/test_claude_hook_missing_socket_error.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: claude-hook stop surfaces a clear socket-connect error when target socket is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_cmux_cli() -> str:
|
||||
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
|
||||
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
|
||||
return explicit
|
||||
|
||||
candidates: list[str] = []
|
||||
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
|
||||
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
|
||||
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
|
||||
if candidates:
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
in_path = shutil.which("cmux")
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
missing_socket = os.path.join(tempfile.gettempdir(), f"cmux-missing-{os.getpid()}.sock")
|
||||
try:
|
||||
if os.path.exists(missing_socket):
|
||||
os.remove(missing_socket)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CMUX_CLI_SENTRY_DISABLED"] = "1"
|
||||
env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
|
||||
env.pop("CMUX_SOCKET_PATH", None)
|
||||
|
||||
proc = subprocess.run(
|
||||
[cli_path, "--socket", missing_socket, "claude-hook", "stop"],
|
||||
input="{}",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if proc.returncode == 0:
|
||||
print("FAIL: expected non-zero exit when socket is missing")
|
||||
print(f"stdout={proc.stdout}")
|
||||
print(f"stderr={proc.stderr}")
|
||||
return 1
|
||||
|
||||
expected_prefixes = [
|
||||
f"Error: Socket not found at {missing_socket}",
|
||||
f"Error: Failed to connect to socket at {missing_socket}",
|
||||
]
|
||||
if not any(prefix in proc.stderr for prefix in expected_prefixes):
|
||||
print("FAIL: missing expected socket error text")
|
||||
print(f"expected one of: {expected_prefixes!r}")
|
||||
print(f"stderr: {proc.stderr!r}")
|
||||
return 1
|
||||
|
||||
print("PASS: claude-hook stop missing-socket error is explicit")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
77
tests/test_cli_sigpipe_ignore.py
Normal file
77
tests/test_cli_sigpipe_ignore.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: cmux CLI should not exit with SIGPIPE on broken stdout pipes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def resolve_cmux_cli() -> str:
|
||||
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
|
||||
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
|
||||
return explicit
|
||||
|
||||
candidates: list[str] = []
|
||||
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
|
||||
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
|
||||
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
|
||||
if candidates:
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
in_path = shutil.which("cmux")
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
|
||||
|
||||
|
||||
def run_with_closed_stdout(cli_path: str, *args: str) -> tuple[int, str]:
|
||||
read_fd, write_fd = os.pipe()
|
||||
os.close(read_fd)
|
||||
proc = subprocess.Popen(
|
||||
[cli_path, *args],
|
||||
stdout=write_fd,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
close_fds=True,
|
||||
)
|
||||
os.close(write_fd)
|
||||
_, stderr = proc.communicate()
|
||||
return proc.returncode, (stderr or "").strip()
|
||||
|
||||
|
||||
def require_zero_exit(cli_path: str, *args: str) -> tuple[bool, str]:
|
||||
code, err = run_with_closed_stdout(cli_path, *args)
|
||||
if code != 0:
|
||||
cmd = " ".join(args)
|
||||
return False, f"`cmux {cmd}` exited {code} with closed stdout pipe (stderr={err!r})"
|
||||
return True, ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
ok_version, version_msg = require_zero_exit(cli_path, "--version")
|
||||
ok_help, help_msg = require_zero_exit(cli_path, "help")
|
||||
|
||||
failures = [msg for msg in [version_msg, help_msg] if msg]
|
||||
if failures:
|
||||
print("FAIL: CLI still fails on broken stdout pipes")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI ignores SIGPIPE and exits cleanly when stdout pipe is closed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
115
tests/test_cli_socket_sentry_scope.py
Normal file
115
tests/test_cli_socket_sentry_scope.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: CLI socket Sentry telemetry must apply to all commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def reject(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
"private final class CLISocketSentryTelemetry {",
|
||||
"Missing CLISocketSentryTelemetry definition",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||',
|
||||
"Missing CMUX_CLI_SENTRY_DISABLED kill switch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"',
|
||||
"Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"private var shouldEmit: Bool {\n !disabledByEnv\n }",
|
||||
"Telemetry scope should be command-agnostic (only disabled by env kill switch)",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'let crumb = Breadcrumb(level: .info, category: "cmux.cli")',
|
||||
"Telemetry breadcrumb category should be cmux.cli",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'"command": command,',
|
||||
"Base telemetry context must include command name",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"let cliTelemetry = CLISocketSentryTelemetry(",
|
||||
"CLI should initialize generic socket telemetry",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'cliTelemetry.breadcrumb(\n "socket.connect.attempt",',
|
||||
"CLI should emit socket.connect.attempt breadcrumb for commands",
|
||||
failures,
|
||||
)
|
||||
|
||||
reject(
|
||||
content,
|
||||
"self.enabled = command == \"claude-hook\"",
|
||||
"Telemetry regressed to claude-hook-only scope",
|
||||
failures,
|
||||
)
|
||||
reject(
|
||||
content,
|
||||
"enabled && !disabledByEnv",
|
||||
"Telemetry still depends on legacy enabled flag",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI socket telemetry scope regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI socket telemetry scope is command-agnostic")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
147
tests/test_cli_subcommand_help_regressions.py
Normal file
147
tests/test_cli_subcommand_help_regressions.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression tests for CLI subcommand help coverage and accuracy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def extract_switch_commands(content: str, start_index: int = 0) -> tuple[set[str], int]:
|
||||
marker = "switch command {"
|
||||
marker_index = content.find(marker, start_index)
|
||||
if marker_index == -1:
|
||||
return set(), -1
|
||||
|
||||
open_brace = content.find("{", marker_index)
|
||||
if open_brace == -1:
|
||||
return set(), -1
|
||||
|
||||
depth = 1
|
||||
cursor = open_brace + 1
|
||||
while cursor < len(content) and depth > 0:
|
||||
char = content[cursor]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
cursor += 1
|
||||
|
||||
block = content[open_brace + 1:cursor - 1]
|
||||
commands: set[str] = set()
|
||||
collecting_case = False
|
||||
case_lines: list[str] = []
|
||||
|
||||
for line in block.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("case "):
|
||||
collecting_case = True
|
||||
case_lines = [line]
|
||||
elif collecting_case:
|
||||
case_lines.append(line)
|
||||
|
||||
if collecting_case and ":" in line:
|
||||
case_text = "\n".join(case_lines)
|
||||
commands.update(re.findall(r'"([^"]+)"', case_text))
|
||||
collecting_case = False
|
||||
case_lines = []
|
||||
|
||||
return commands, cursor
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
'if commandArgs.contains("--help") || commandArgs.contains("-h") {',
|
||||
"Subcommand help pre-dispatch gate is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {',
|
||||
"Subcommand help dispatch call is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"print(\"Unknown command '\\(command)'. Run 'cmux help' to see available commands.\")",
|
||||
"Subcommand help fallback unknown-command line is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"print(\"Unknown command '\\(command)'. Run 'cmux help' to see available commands.\")\n return",
|
||||
"Subcommand help fallback must return before command execution",
|
||||
failures,
|
||||
)
|
||||
|
||||
dispatch_commands, next_index = extract_switch_commands(content, 0)
|
||||
subcommand_usage_commands, _ = extract_switch_commands(content, next_index if next_index != -1 else 0)
|
||||
if not dispatch_commands:
|
||||
failures.append("Failed to parse main dispatch switch command list")
|
||||
if not subcommand_usage_commands:
|
||||
failures.append("Failed to parse subcommandUsage switch command list")
|
||||
|
||||
missing_help_entries = sorted(dispatch_commands - subcommand_usage_commands)
|
||||
if missing_help_entries:
|
||||
failures.append(
|
||||
"Missing subcommandUsage entries for dispatch command(s): "
|
||||
+ ", ".join(missing_help_entries)
|
||||
)
|
||||
|
||||
# Regression checks for concrete help text that previously drifted from dispatch logic.
|
||||
for needle, message in [
|
||||
('case "help":', "Missing subcommandUsage entry for help"),
|
||||
("Usage: cmux help", "help subcommand usage text is missing"),
|
||||
("Usage: cmux move-workspace-to-window --workspace <id|ref|index> --window <id|ref|index>", "move-workspace-to-window help must document index handles"),
|
||||
("--tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab)", "tab-action help must document CMUX_TAB_ID/CMUX_SURFACE_ID fallback"),
|
||||
("--workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID)", "rename-workspace help must document CMUX_WORKSPACE_ID fallback"),
|
||||
("text|html|value|count|box|styles|attr: [--selector <css> | <css>]", "browser get help must document --selector"),
|
||||
("attr: [--attr <name> | <name>]", "browser get attr help must document --attr"),
|
||||
("styles: [--property <name>]", "browser get styles help must document --property"),
|
||||
("role: [--name <text>] [--exact] <role>", "browser find role help must document --name/--exact"),
|
||||
("text|label|placeholder|alt|title|testid: [--exact] <text>", "browser find text-like help must document --exact"),
|
||||
("nth: [--index <n> | <n>] [--selector <css> | <css>]", "browser find nth help must document --index/--selector"),
|
||||
("route <pattern> [--abort] [--body <text>]", "browser network route help must document --abort/--body"),
|
||||
]:
|
||||
require(content, needle, message, failures)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI subcommand help regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI subcommand help coverage and flag/env documentation are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
149
tests/test_cli_tree_command.py
Normal file
149
tests/test_cli_tree_command.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: `cmux tree` command wiring and output contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
controller_path = repo_root / "Sources" / "TerminalController.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
if not controller_path.exists():
|
||||
print(f"FAIL: missing expected file: {controller_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
controller_content = controller_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
'case "tree":\n try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)',
|
||||
"Missing `tree` command dispatch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"tree [--all] [--workspace <id|ref|index>]",
|
||||
"Top-level usage text missing tree command",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"Usage: cmux tree [flags]",
|
||||
"Subcommand help for `cmux tree --help` is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"Known flags: --all --workspace <id|ref|index> --json",
|
||||
"Tree flag validation for --all/--workspace is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"--json Structured JSON output",
|
||||
"Tree help text should document --json",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'print(jsonString(formatIDs(payload, mode: idFormat)))',
|
||||
"Tree command JSON output should honor --id-format conversion",
|
||||
failures,
|
||||
)
|
||||
|
||||
# Data sources needed for full hierarchy + browser URLs.
|
||||
for method in [
|
||||
'method: "system.tree"',
|
||||
'method: "system.identify"',
|
||||
'method: "window.list"',
|
||||
'method: "workspace.list"',
|
||||
'method: "pane.list"',
|
||||
'method: "surface.list"',
|
||||
'method: "browser.tab.list"',
|
||||
'method: "browser.url.get"',
|
||||
]:
|
||||
require(
|
||||
content,
|
||||
method,
|
||||
f"Tree command is missing expected API call: {method}",
|
||||
failures,
|
||||
)
|
||||
|
||||
# Text tree rendering contract.
|
||||
for glyph in ['"├── "', '"└── "', '"│ "']:
|
||||
require(
|
||||
content,
|
||||
glyph,
|
||||
f"Tree output missing box-drawing glyph: {glyph}",
|
||||
failures,
|
||||
)
|
||||
|
||||
for marker in ["[current]", "[selected]", "[focused]", "◀ active", "◀ here"]:
|
||||
require(
|
||||
content,
|
||||
marker,
|
||||
f"Tree output missing required marker: {marker}",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
content,
|
||||
'surfaceType.lowercased() == "browser"',
|
||||
"Tree surface rendering should special-case browser surfaces",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'let url = surface["url"] as? String',
|
||||
"Tree surface rendering should include browser URL when available",
|
||||
failures,
|
||||
)
|
||||
|
||||
# Server-side one-shot hierarchy path for performance.
|
||||
for needle, message in [
|
||||
('case "system.tree":', "Socket router is missing system.tree dispatch"),
|
||||
('"system.tree"', "Capabilities list should advertise system.tree"),
|
||||
("private func v2SystemTree(params: [String: Any]) -> V2CallResult {", "Missing v2SystemTree implementation"),
|
||||
('"active":', "system.tree payload should include focused path"),
|
||||
('"caller":', "system.tree payload should include caller path"),
|
||||
('"windows":', "system.tree payload should include hierarchy windows"),
|
||||
]:
|
||||
require(controller_content, needle, message, failures)
|
||||
|
||||
if failures:
|
||||
print("FAIL: cmux tree command regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: cmux tree command wiring and output contract are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
85
tests/test_cli_version_commit_metadata.py
Normal file
85
tests/test_cli_version_commit_metadata.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: CLI version output wiring keeps commit metadata support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
'let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }',
|
||||
"versionSummary no longer reads CMUXCommit metadata",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'return "\\(baseSummary) [\\(commit)]"',
|
||||
"versionSummary no longer appends commit metadata",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'if let commit = dictionary["CMUXCommit"] as? String,',
|
||||
"Info.plist parsing no longer reads CMUXCommit",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"if let commit = gitCommitHash(at: current) {",
|
||||
"Project fallback no longer probes git commit hash",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]',
|
||||
"Git commit probe command changed unexpectedly",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"])',
|
||||
"Environment commit fallback (CMUX_COMMIT) is missing",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI version commit metadata regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI version commit metadata wiring is intact")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
87
tests/test_cli_version_flag.py
Normal file
87
tests/test_cli_version_flag.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux --version` should print version text without requiring a socket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def resolve_cmux_cli() -> str:
|
||||
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
|
||||
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
|
||||
return explicit
|
||||
|
||||
candidates: list[str] = []
|
||||
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
|
||||
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
|
||||
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
|
||||
if candidates:
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
in_path = shutil.which("cmux")
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
|
||||
|
||||
|
||||
def run(cli_path: str, *args: str) -> tuple[int, str, str]:
|
||||
proc = subprocess.run(
|
||||
[cli_path, *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
code, out, err = run(cli_path, "--version")
|
||||
if code != 0:
|
||||
print("FAIL: `cmux --version` exited non-zero")
|
||||
print(f"exit={code}")
|
||||
print(f"stdout={out}")
|
||||
print(f"stderr={err}")
|
||||
return 1
|
||||
|
||||
if not out:
|
||||
print("FAIL: `cmux --version` produced empty stdout")
|
||||
return 1
|
||||
|
||||
if not re.search(r"\b\d+\.\d+\.\d+\b", out):
|
||||
print(f"FAIL: version output missing semantic version: {out!r}")
|
||||
return 1
|
||||
|
||||
code2, out2, err2 = run(cli_path, "version")
|
||||
if code2 != 0:
|
||||
print("FAIL: `cmux version` exited non-zero")
|
||||
print(f"exit={code2}")
|
||||
print(f"stdout={out2}")
|
||||
print(f"stderr={err2}")
|
||||
return 1
|
||||
|
||||
if out2 != out:
|
||||
print("FAIL: `cmux --version` and `cmux version` differ")
|
||||
print(f"--version: {out!r}")
|
||||
print(f"version: {out2!r}")
|
||||
return 1
|
||||
|
||||
print(f"PASS: cmux version command works ({out})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
140
tests/test_cmd_option_t_close_other_tabs_in_pane.py
Normal file
140
tests/test_cmd_option_t_close_other_tabs_in_pane.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: Cmd+Option+T closes all other tabs in the focused pane
|
||||
after an explicit confirmation.
|
||||
|
||||
Run this against an app launched with CMUX_SOCKET_MODE=allowAll.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval_s)
|
||||
return False
|
||||
|
||||
|
||||
def _pane_state(client: cmux) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for index, panel_id, title, selected in client.list_pane_surfaces():
|
||||
rows.append(
|
||||
{
|
||||
"index": index,
|
||||
"panel_id": panel_id,
|
||||
"title": title,
|
||||
"selected": selected,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _send_shortcut_via_system_events(key: str, modifiers: str) -> None:
|
||||
script = f'tell application "System Events" to keystroke "{key}" using {{{modifiers}}}'
|
||||
try:
|
||||
subprocess.run(["osascript", "-e", script], check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = (exc.stderr or "").strip()
|
||||
raise cmuxError(
|
||||
"Failed to send keyboard shortcut via System Events. "
|
||||
f"Ensure macOS Accessibility automation is enabled. stderr={stderr}"
|
||||
) from exc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
if not client.ping():
|
||||
raise cmuxError(
|
||||
f"Socket ping failed on {SOCKET_PATH}. "
|
||||
"Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test."
|
||||
)
|
||||
|
||||
workspace_id = client.new_workspace()
|
||||
try:
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.25)
|
||||
client.activate_app()
|
||||
time.sleep(0.15)
|
||||
|
||||
# Create two additional tabs in the current focused pane.
|
||||
client.new_surface()
|
||||
client.new_surface()
|
||||
time.sleep(0.25)
|
||||
|
||||
before = _pane_state(client)
|
||||
if len(before) < 3:
|
||||
raise cmuxError(f"Expected >=3 tabs before shortcut, got {before}")
|
||||
|
||||
selected_rows = [row for row in before if row["selected"]]
|
||||
if len(selected_rows) != 1:
|
||||
raise cmuxError(f"Expected exactly one selected tab before shortcut, got {before}")
|
||||
selected_panel_id = selected_rows[0]["panel_id"]
|
||||
|
||||
expected_to_close = [row for row in before if row["panel_id"] != selected_panel_id]
|
||||
if len(expected_to_close) < 2:
|
||||
raise cmuxError(
|
||||
f"Expected at least two non-selected tabs before shortcut, got {before}"
|
||||
)
|
||||
|
||||
# Trigger shortcut via real OS key event; this should open the confirmation dialog.
|
||||
_send_shortcut_via_system_events("t", "command down, option down")
|
||||
time.sleep(0.25)
|
||||
after_trigger = _pane_state(client)
|
||||
if len(after_trigger) != len(before):
|
||||
raise cmuxError(
|
||||
"Cmd+Option+T should require confirmation before closing.\n"
|
||||
f"before={before}\n"
|
||||
f"after_trigger={after_trigger}"
|
||||
)
|
||||
|
||||
# Confirm the dialog with Cmd+D (wired to click the destructive "Close" button).
|
||||
_send_shortcut_via_system_events("d", "command down")
|
||||
closed = _wait_until(lambda: len(_pane_state(client)) == 1, timeout_s=5.0, interval_s=0.05)
|
||||
if not closed:
|
||||
raise cmuxError(
|
||||
"Timed out waiting for tabs to close after confirming Cmd+Option+T.\n"
|
||||
f"before={before}\n"
|
||||
f"after_trigger={after_trigger}\n"
|
||||
f"after_confirm={_pane_state(client)}"
|
||||
)
|
||||
|
||||
after_confirm = _pane_state(client)
|
||||
if len(after_confirm) != 1:
|
||||
raise cmuxError(
|
||||
f"Expected one remaining tab after confirmation, got {after_confirm}"
|
||||
)
|
||||
remaining = after_confirm[0]
|
||||
if remaining["panel_id"] != selected_panel_id:
|
||||
raise cmuxError(
|
||||
"Expected selected tab to remain after closing others.\n"
|
||||
f"expected_selected={selected_panel_id}\n"
|
||||
f"remaining={remaining}\n"
|
||||
f"before={before}"
|
||||
)
|
||||
|
||||
print("PASS: Cmd+Option+T closed all other tabs in focused pane.")
|
||||
print(f"workspace={workspace_id}")
|
||||
print(f"selected_panel={selected_panel_id}")
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue