Merge remote-tracking branch 'origin/main' into pr-374-ssh-remote-cli-relay
# Conflicts: # CLI/cmux.swift # Sources/ContentView.swift # Sources/GhosttyTerminalView.swift # Sources/Panels/TerminalPanel.swift # Sources/TabManager.swift # Sources/TerminalController.swift # Sources/Workspace.swift
This commit is contained in:
commit
7da2357a16
135 changed files with 30520 additions and 719 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)
|
||||
```
|
||||
|
|
|
|||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -7,6 +7,15 @@ on:
|
|||
pull_request:
|
||||
|
||||
jobs:
|
||||
workflow-guard-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate self-hosted runner guards
|
||||
run: ./tests/test_ci_self_hosted_guard.sh
|
||||
|
||||
web-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
|
@ -26,6 +35,8 @@ jobs:
|
|||
run: bun tsc --noEmit
|
||||
|
||||
ui-tests:
|
||||
# Never run self-hosted jobs for fork pull requests.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: self-hosted
|
||||
concurrency:
|
||||
group: self-hosted-build
|
||||
|
|
|
|||
27
.github/workflows/nightly.yml
vendored
27
.github/workflows/nightly.yml
vendored
|
|
@ -190,6 +190,12 @@ jobs:
|
|||
fi
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$APP_PLIST"
|
||||
|
||||
# Use an immutable DMG filename in appcast URLs so old appcasts keep
|
||||
# pointing at matching archives while nightly assets roll forward.
|
||||
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
|
||||
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
|
||||
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
|
||||
|
||||
# Embed commit SHA for bug reports
|
||||
/usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$APP_PLIST" >/dev/null 2>&1 || true
|
||||
/usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$APP_PLIST"
|
||||
|
|
@ -198,6 +204,7 @@ jobs:
|
|||
echo "Nightly bundle ID: com.cmuxterm.app.nightly"
|
||||
echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}"
|
||||
echo "Nightly build number: ${NIGHTLY_BUILD}"
|
||||
echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}"
|
||||
echo "Commit SHA: ${SHORT_SHA}"
|
||||
|
||||
- name: Import signing cert
|
||||
|
|
@ -283,6 +290,23 @@ jobs:
|
|||
xcrun stapler staple "$DMG_RELEASE"
|
||||
xcrun stapler validate "$DMG_RELEASE"
|
||||
|
||||
# Keep a stable filename for humans and an immutable filename used
|
||||
# by appcast URLs to prevent signature/asset mismatch races.
|
||||
cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE"
|
||||
|
||||
- name: Upload dSYMs to Sentry
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: manaflow
|
||||
SENTRY_PROJECT: cmuxterm-macos
|
||||
run: |
|
||||
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
|
||||
echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload"
|
||||
exit 0
|
||||
fi
|
||||
brew install getsentry/tools/sentry-cli || true
|
||||
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
|
||||
|
||||
- name: Generate Sparkle appcast (nightly)
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
|
|
@ -291,7 +315,7 @@ jobs:
|
|||
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/sparkle_generate_appcast.sh cmux-nightly-macos.dmg nightly appcast.xml
|
||||
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml
|
||||
|
||||
- name: Move nightly tag to built commit
|
||||
run: |
|
||||
|
|
@ -315,6 +339,7 @@ jobs:
|
|||
|
||||
[Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
files: |
|
||||
cmux-nightly-macos-${{ github.run_id }}*.dmg
|
||||
cmux-nightly-macos.dmg
|
||||
appcast.xml
|
||||
overwrite_files: true
|
||||
|
|
|
|||
84
.github/workflows/release.yml
vendored
84
.github/workflows/release.yml
vendored
|
|
@ -21,7 +21,63 @@ jobs:
|
|||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Guard immutable release assets
|
||||
id: guard_release_assets
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard');
|
||||
const tag = context.ref.replace('refs/tags/', '');
|
||||
core.setOutput('skip_all', 'false');
|
||||
core.setOutput('skip_upload', 'false');
|
||||
core.setOutput('release_state', 'clear');
|
||||
try {
|
||||
const release = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag,
|
||||
});
|
||||
const existingAssetNames = (release.data.assets || []).map((asset) => asset.name);
|
||||
const {
|
||||
conflicts,
|
||||
missingImmutableAssets,
|
||||
guardState,
|
||||
hasPartialConflict,
|
||||
shouldSkipBuildAndUpload,
|
||||
} = evaluateReleaseAssetGuard({ existingAssetNames });
|
||||
|
||||
core.setOutput('release_state', guardState);
|
||||
|
||||
if (hasPartialConflict) {
|
||||
core.setFailed(
|
||||
`Release ${tag} has a partial immutable asset state. Existing immutable assets: ` +
|
||||
`${conflicts.join(', ')}. Missing immutable assets: ${missingImmutableAssets.join(', ')}. ` +
|
||||
'Resolve release assets manually before rerunning.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSkipBuildAndUpload) {
|
||||
core.notice(
|
||||
`Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` +
|
||||
'Skipping build, notarization, and upload to preserve existing signed artifacts.'
|
||||
);
|
||||
core.setOutput('skip_all', 'true');
|
||||
core.setOutput('skip_upload', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.notice(`Release ${tag} exists but has no immutable release assets yet; continuing.`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.notice(`Release ${tag} does not exist yet; safe to build and publish assets.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
- name: Select Xcode
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
||||
|
|
@ -41,15 +97,18 @@ jobs:
|
|||
xcrun --sdk macosx --show-sdk-path
|
||||
|
||||
- name: Install build deps
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
brew update
|
||||
brew install zig
|
||||
npm install --global create-dmg
|
||||
|
||||
- name: Download Metal Toolchain
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: xcodebuild -downloadComponent MetalToolchain
|
||||
|
||||
- name: Build GhosttyKit.xcframework
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
cd ghostty
|
||||
zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast
|
||||
|
|
@ -58,11 +117,13 @@ jobs:
|
|||
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
|
||||
|
||||
- name: Clear SPM cache
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
|
||||
- name: Configure SwiftPM cache
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}"
|
||||
|
|
@ -71,6 +132,7 @@ jobs:
|
|||
echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Derive Sparkle public key from private key
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
run: |
|
||||
|
|
@ -83,10 +145,12 @@ jobs:
|
|||
echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build app (Release)
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
- name: Inject Sparkle keys into Info.plist
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true
|
||||
|
|
@ -100,6 +164,7 @@ jobs:
|
|||
/usr/libexec/PlistBuddy -c "Print :SUFeedURL" "$APP_PLIST"
|
||||
|
||||
- name: Import signing cert
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
|
@ -123,6 +188,7 @@ jobs:
|
|||
security list-keychains -d user -s build.keychain
|
||||
|
||||
- name: Codesign app
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
|
|
@ -140,6 +206,7 @@ jobs:
|
|||
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
|
||||
- name: Notarize app
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
|
|
@ -183,7 +250,22 @@ jobs:
|
|||
xcrun stapler staple "$DMG_RELEASE"
|
||||
xcrun stapler validate "$DMG_RELEASE"
|
||||
|
||||
- name: Upload dSYMs to Sentry
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: manaflow
|
||||
SENTRY_PROJECT: cmuxterm-macos
|
||||
run: |
|
||||
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
|
||||
echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload"
|
||||
exit 0
|
||||
fi
|
||||
brew install getsentry/tools/sentry-cli || true
|
||||
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
|
||||
|
||||
- name: Generate Sparkle appcast
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
run: |
|
||||
|
|
@ -194,12 +276,14 @@ jobs:
|
|||
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml
|
||||
|
||||
- name: Upload release asset
|
||||
if: steps.guard_release_assets.outputs.skip_upload != 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
cmux-macos.dmg
|
||||
appcast.xml
|
||||
generate_release_notes: true
|
||||
overwrite_files: false
|
||||
|
||||
- name: Cleanup keychain
|
||||
if: always()
|
||||
|
|
|
|||
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
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -2,6 +2,62 @@
|
|||
|
||||
All notable changes to cmux are documented here.
|
||||
|
||||
## [0.60.0] - 2026-02-21
|
||||
|
||||
### Added
|
||||
- Tab context menu with rename, close, unread, and workspace actions ([#225](https://github.com/manaflow-ai/cmux/pull/225))
|
||||
- Cmd+Shift+T reopens closed browser panels ([#253](https://github.com/manaflow-ai/cmux/pull/253))
|
||||
- Vertical sidebar branch layout setting showing git branch and directory per pane
|
||||
- JavaScript alert/confirm/prompt dialogs in browser panel ([#237](https://github.com/manaflow-ai/cmux/pull/237))
|
||||
- File drag-and-drop and file input in browser panel ([#214](https://github.com/manaflow-ai/cmux/pull/214))
|
||||
- tmux-compatible command set with matrix tests ([#221](https://github.com/manaflow-ai/cmux/pull/221))
|
||||
- Pane resize divider control via CLI ([#223](https://github.com/manaflow-ai/cmux/pull/223))
|
||||
- Production read-screen capture APIs ([#219](https://github.com/manaflow-ai/cmux/pull/219))
|
||||
- Notification rings on terminal panes ([#132](https://github.com/manaflow-ai/cmux/pull/132))
|
||||
- Claude Code integration enabled by default ([#247](https://github.com/manaflow-ai/cmux/pull/247))
|
||||
- HTTP host allowlist for embedded browser with save and proceed flow ([#206](https://github.com/manaflow-ai/cmux/pull/206), [#203](https://github.com/manaflow-ai/cmux/pull/203))
|
||||
- Setting to disable workspace auto-reorder on notification ([#215](https://github.com/manaflow-ai/cmux/issues/205))
|
||||
- Browser panel mouse back/forward buttons and middle-click close ([#139](https://github.com/manaflow-ai/cmux/pull/139))
|
||||
- Browser DevTools shortcut wiring and persistence ([#117](https://github.com/manaflow-ai/cmux/pull/117))
|
||||
- CJK IME input support for Korean, Chinese, and Japanese ([#125](https://github.com/manaflow-ai/cmux/pull/125))
|
||||
- `--help` flag on CLI subcommands ([#128](https://github.com/manaflow-ai/cmux/pull/128))
|
||||
- `--command` flag for `new-workspace` CLI command ([#121](https://github.com/manaflow-ai/cmux/pull/121))
|
||||
- `rename-tab` socket command ([#260](https://github.com/manaflow-ai/cmux/pull/260))
|
||||
- Remap-aware bonsplit tooltips and browser split shortcuts ([#200](https://github.com/manaflow-ai/cmux/pull/200))
|
||||
|
||||
### Fixed
|
||||
- IME preedit anchor sizing ([#266](https://github.com/manaflow-ai/cmux/pull/266))
|
||||
- Cmd+Shift+T focus against deferred stale callbacks ([#267](https://github.com/manaflow-ai/cmux/pull/267))
|
||||
- Unknown Bonsplit tab context actions causing crash ([#264](https://github.com/manaflow-ai/cmux/pull/264))
|
||||
- Socket CLI commands stealing macOS app focus ([#260](https://github.com/manaflow-ai/cmux/pull/260))
|
||||
- CLI unix socket lag from main-thread blocking ([#259](https://github.com/manaflow-ai/cmux/pull/259))
|
||||
- Main-thread notification cascade causing hangs ([#232](https://github.com/manaflow-ai/cmux/pull/232))
|
||||
- Favicon out-of-sync during back/forward navigation ([#233](https://github.com/manaflow-ai/cmux/pull/233))
|
||||
- Stale sidebar git branch after closing a split
|
||||
- Browser download UX and crash path ([#235](https://github.com/manaflow-ai/cmux/pull/235))
|
||||
- Browser reopen focus across workspace switches ([#257](https://github.com/manaflow-ai/cmux/pull/257))
|
||||
- Mark Tab as Unread no-op on focused tab ([#249](https://github.com/manaflow-ai/cmux/pull/249))
|
||||
- Split dividers disappearing in tiny panes ([#250](https://github.com/manaflow-ai/cmux/pull/250))
|
||||
- Flaky browser download activity accounting ([#246](https://github.com/manaflow-ai/cmux/pull/246))
|
||||
- Drag overlay routing and terminal overlay regressions ([#218](https://github.com/manaflow-ai/cmux/pull/218))
|
||||
- Initial bonsplit split animation flicker
|
||||
- Window top inset on new window creation ([#224](https://github.com/manaflow-ai/cmux/pull/224))
|
||||
- Cmd+Enter being routed as browser reload ([#213](https://github.com/manaflow-ai/cmux/pull/213))
|
||||
- Child-exit close for last-terminal workspaces ([#254](https://github.com/manaflow-ai/cmux/pull/254))
|
||||
- Sidebar resizer hitbox and cursor across portals ([#255](https://github.com/manaflow-ai/cmux/pull/255))
|
||||
- Workspace-scoped tab action resolution
|
||||
- IDN host allowlist normalization
|
||||
- `setup.sh` cache rebuild and stale lock timeout ([#217](https://github.com/manaflow-ai/cmux/pull/217))
|
||||
- Inconsistent Tab/Workspace terminology in settings and menus ([#187](https://github.com/manaflow-ai/cmux/pull/187))
|
||||
|
||||
### Changed
|
||||
- CLI workspace commands now run off the main thread for better responsiveness ([#270](https://github.com/manaflow-ai/cmux/pull/270))
|
||||
- Remove border below titlebar ([#242](https://github.com/manaflow-ai/cmux/pull/242))
|
||||
- Slimmer browser omnibar with button hover/press states ([#271](https://github.com/manaflow-ai/cmux/pull/271))
|
||||
- Browser under-page background refreshes on theme updates ([#272](https://github.com/manaflow-ai/cmux/pull/272))
|
||||
- Command shortcut hints scoped to active window ([#226](https://github.com/manaflow-ai/cmux/pull/226))
|
||||
- Nightly and release assets are now immutable (no accidental overwrite) ([#268](https://github.com/manaflow-ai/cmux/pull/268), [#269](https://github.com/manaflow-ai/cmux/pull/269))
|
||||
|
||||
## [0.59.0] - 2026-02-19
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -93,8 +93,25 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
|
|||
|
||||
- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`).
|
||||
- Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.
|
||||
- **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.
|
||||
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <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`:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; };
|
||||
A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; };
|
||||
A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; };
|
||||
A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; };
|
||||
A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; };
|
||||
A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; };
|
||||
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
|
||||
|
|
@ -54,11 +55,13 @@
|
|||
A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; };
|
||||
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; };
|
||||
A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; };
|
||||
A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; };
|
||||
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
|
||||
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
|
||||
B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; };
|
||||
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; };
|
||||
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; };
|
||||
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; };
|
||||
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; };
|
||||
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; };
|
||||
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
|
||||
|
|
@ -71,11 +74,14 @@
|
|||
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
|
||||
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
|
||||
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
|
||||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
A5001020 /* Embed Frameworks */ = {
|
||||
|
|
@ -96,6 +102,7 @@
|
|||
files = (
|
||||
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */,
|
||||
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */,
|
||||
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */,
|
||||
);
|
||||
name = "Copy CLI";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -144,6 +151,7 @@
|
|||
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<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>"; };
|
||||
|
|
@ -177,11 +185,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 +200,17 @@
|
|||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A5001030 /* Frameworks */ = {
|
||||
|
|
@ -319,6 +332,7 @@
|
|||
A5001019 /* TerminalController.swift */,
|
||||
A5001541 /* PortScanner.swift */,
|
||||
A5001225 /* SocketControlSettings.swift */,
|
||||
A5001600 /* SentryHelper.swift */,
|
||||
A5001090 /* AppDelegate.swift */,
|
||||
A5001091 /* NotificationsPage.swift */,
|
||||
A5001092 /* TerminalNotificationStore.swift */,
|
||||
|
|
@ -345,6 +359,7 @@
|
|||
A5001219 /* WindowToolbarController.swift */,
|
||||
A5001241 /* WindowDecorationsController.swift */,
|
||||
A5001222 /* WindowAccessor.swift */,
|
||||
A5001611 /* SessionPersistence.swift */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -395,17 +410,20 @@
|
|||
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 */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -548,6 +566,7 @@
|
|||
A5001007 /* TerminalController.swift in Sources */,
|
||||
A5001540 /* PortScanner.swift in Sources */,
|
||||
A5001226 /* SocketControlSettings.swift in Sources */,
|
||||
A5001601 /* SentryHelper.swift in Sources */,
|
||||
A5001093 /* AppDelegate.swift in Sources */,
|
||||
A5001094 /* NotificationsPage.swift in Sources */,
|
||||
A5001095 /* TerminalNotificationStore.swift in Sources */,
|
||||
|
|
@ -574,6 +593,7 @@
|
|||
A5001209 /* WindowToolbarController.swift in Sources */,
|
||||
A5001240 /* WindowDecorationsController.swift in Sources */,
|
||||
A500120C /* WindowAccessor.swift in Sources */,
|
||||
A5001610 /* SessionPersistence.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -594,18 +614,21 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F1000005A1B2C3D4E5F60718 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9000006A1B2C3D4E5F60719 /* Sources */ = {
|
||||
F1000005A1B2C3D4E5F60718 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
|
||||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9000006A1B2C3D4E5F60719 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -702,7 +725,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 72;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -711,7 +734,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.60.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -741,7 +764,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 72;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -750,7 +773,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.60.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -804,10 +827,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 72;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.60.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -821,10 +844,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 72;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.60.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -838,10 +861,10 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 72;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.60.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -857,10 +880,10 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
CURRENT_PROJECT_VERSION = 72;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.59.0;
|
||||
MARKETING_VERSION = 0.60.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -114,6 +114,7 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
|
|||
| ⌃ ⌘ ] | Next workspace |
|
||||
| ⌃ ⌘ [ | Previous workspace |
|
||||
| ⌘ ⇧ W | Close workspace |
|
||||
| ⌘ ⇧ R | Rename workspace |
|
||||
| ⌘ B | Toggle sidebar |
|
||||
|
||||
### Surfaces
|
||||
|
|
@ -193,9 +194,19 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in
|
|||
|
||||
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
|
||||
|
||||
## Star History
|
||||
|
||||
<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>
|
||||
|
||||
## 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)
|
||||
|
|
|
|||
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
|
||||
|
|
@ -23,6 +23,18 @@ _cmux_send() {
|
|||
fi
|
||||
}
|
||||
|
||||
_cmux_restore_scrollback_once() {
|
||||
local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}"
|
||||
[[ -n "$path" ]] || return 0
|
||||
unset CMUX_RESTORE_SCROLLBACK_FILE
|
||||
|
||||
if [[ -r "$path" ]]; then
|
||||
/bin/cat -- "$path" 2>/dev/null || true
|
||||
/bin/rm -f -- "$path" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
_cmux_restore_scrollback_once
|
||||
|
||||
# Throttle heavy work to avoid prompt latency.
|
||||
_CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
|
||||
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
|
||||
|
|
@ -107,9 +119,9 @@ _cmux_prompt_command() {
|
|||
local first
|
||||
first=$(git status --porcelain -uno 2>/dev/null | head -1)
|
||||
[[ -n "$first" ]] && dirty_opt="--status=dirty"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_GIT_JOB_PID=$!
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ _cmux_send() {
|
|||
fi
|
||||
}
|
||||
|
||||
_cmux_restore_scrollback_once() {
|
||||
local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}"
|
||||
[[ -n "$path" ]] || return 0
|
||||
unset CMUX_RESTORE_SCROLLBACK_FILE
|
||||
|
||||
if [[ -r "$path" ]]; then
|
||||
/bin/cat -- "$path" 2>/dev/null || true
|
||||
/bin/rm -f -- "$path" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
_cmux_restore_scrollback_once
|
||||
|
||||
# Throttle heavy work to avoid prompt latency.
|
||||
typeset -g _CMUX_PWD_LAST_PWD=""
|
||||
typeset -g _CMUX_GIT_LAST_PWD=""
|
||||
|
|
@ -240,9 +252,9 @@ _cmux_precmd() {
|
|||
local first
|
||||
first=$(git status --porcelain -uno 2>/dev/null | head -1)
|
||||
[[ -n "$first" ]] && dirty_opt="--status=dirty"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
} >/dev/null 2>&1 &!
|
||||
_CMUX_GIT_JOB_PID=$!
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ enum KeyboardShortcutSettings {
|
|||
case toggleSidebar
|
||||
case newTab
|
||||
case newWindow
|
||||
case closeWindow
|
||||
case showNotifications
|
||||
case jumpToUnread
|
||||
case triggerFlash
|
||||
|
|
@ -17,6 +18,9 @@ enum KeyboardShortcutSettings {
|
|||
case prevSurface
|
||||
case nextSidebarTab
|
||||
case prevSidebarTab
|
||||
case renameTab
|
||||
case renameWorkspace
|
||||
case closeWorkspace
|
||||
case newSurface
|
||||
|
||||
// Panes / splits
|
||||
|
|
@ -41,6 +45,7 @@ enum KeyboardShortcutSettings {
|
|||
case .toggleSidebar: return "Toggle Sidebar"
|
||||
case .newTab: return "New Workspace"
|
||||
case .newWindow: return "New Window"
|
||||
case .closeWindow: return "Close Window"
|
||||
case .showNotifications: return "Show Notifications"
|
||||
case .jumpToUnread: return "Jump to Latest Unread"
|
||||
case .triggerFlash: return "Flash Focused Panel"
|
||||
|
|
@ -48,6 +53,9 @@ enum KeyboardShortcutSettings {
|
|||
case .prevSurface: return "Previous Surface"
|
||||
case .nextSidebarTab: return "Next Workspace"
|
||||
case .prevSidebarTab: return "Previous Workspace"
|
||||
case .renameTab: return "Rename Tab"
|
||||
case .renameWorkspace: return "Rename Workspace"
|
||||
case .closeWorkspace: return "Close Workspace"
|
||||
case .newSurface: return "New Surface"
|
||||
case .focusLeft: return "Focus Pane Left"
|
||||
case .focusRight: return "Focus Pane Right"
|
||||
|
|
@ -68,11 +76,15 @@ enum KeyboardShortcutSettings {
|
|||
case .toggleSidebar: return "shortcut.toggleSidebar"
|
||||
case .newTab: return "shortcut.newTab"
|
||||
case .newWindow: return "shortcut.newWindow"
|
||||
case .closeWindow: return "shortcut.closeWindow"
|
||||
case .showNotifications: return "shortcut.showNotifications"
|
||||
case .jumpToUnread: return "shortcut.jumpToUnread"
|
||||
case .triggerFlash: return "shortcut.triggerFlash"
|
||||
case .nextSidebarTab: return "shortcut.nextSidebarTab"
|
||||
case .prevSidebarTab: return "shortcut.prevSidebarTab"
|
||||
case .renameTab: return "shortcut.renameTab"
|
||||
case .renameWorkspace: return "shortcut.renameWorkspace"
|
||||
case .closeWorkspace: return "shortcut.closeWorkspace"
|
||||
case .focusLeft: return "shortcut.focusLeft"
|
||||
case .focusRight: return "shortcut.focusRight"
|
||||
case .focusUp: return "shortcut.focusUp"
|
||||
|
|
@ -98,6 +110,8 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
|
||||
case .newWindow:
|
||||
return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false)
|
||||
case .closeWindow:
|
||||
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
|
||||
case .showNotifications:
|
||||
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
|
||||
case .jumpToUnread:
|
||||
|
|
@ -108,6 +122,12 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true)
|
||||
case .prevSidebarTab:
|
||||
return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true)
|
||||
case .renameTab:
|
||||
return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false)
|
||||
case .renameWorkspace:
|
||||
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
|
||||
case .closeWorkspace:
|
||||
return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false)
|
||||
case .focusLeft:
|
||||
return StoredShortcut(key: "←", command: true, shift: false, option: true, control: false)
|
||||
case .focusRight:
|
||||
|
|
@ -190,6 +210,8 @@ enum KeyboardShortcutSettings {
|
|||
|
||||
static func nextSidebarTabShortcut() -> StoredShortcut { shortcut(for: .nextSidebarTab) }
|
||||
static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) }
|
||||
static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) }
|
||||
static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) }
|
||||
|
||||
static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) }
|
||||
static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) }
|
||||
|
|
@ -244,6 +266,65 @@ struct StoredShortcut: Codable, Equatable {
|
|||
return flags
|
||||
}
|
||||
|
||||
var keyEquivalent: KeyEquivalent? {
|
||||
switch key {
|
||||
case "←":
|
||||
return .leftArrow
|
||||
case "→":
|
||||
return .rightArrow
|
||||
case "↑":
|
||||
return .upArrow
|
||||
case "↓":
|
||||
return .downArrow
|
||||
case "\t":
|
||||
return .tab
|
||||
default:
|
||||
let lowered = key.lowercased()
|
||||
guard lowered.count == 1, let character = lowered.first else { return nil }
|
||||
return KeyEquivalent(character)
|
||||
}
|
||||
}
|
||||
|
||||
var eventModifiers: EventModifiers {
|
||||
var modifiers: EventModifiers = []
|
||||
if command {
|
||||
modifiers.insert(.command)
|
||||
}
|
||||
if shift {
|
||||
modifiers.insert(.shift)
|
||||
}
|
||||
if option {
|
||||
modifiers.insert(.option)
|
||||
}
|
||||
if control {
|
||||
modifiers.insert(.control)
|
||||
}
|
||||
return modifiers
|
||||
}
|
||||
|
||||
var menuItemKeyEquivalent: String? {
|
||||
switch key {
|
||||
case "←":
|
||||
guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil }
|
||||
return String(Character(scalar))
|
||||
case "→":
|
||||
guard let scalar = UnicodeScalar(NSRightArrowFunctionKey) else { return nil }
|
||||
return String(Character(scalar))
|
||||
case "↑":
|
||||
guard let scalar = UnicodeScalar(NSUpArrowFunctionKey) else { return nil }
|
||||
return String(Character(scalar))
|
||||
case "↓":
|
||||
guard let scalar = UnicodeScalar(NSDownArrowFunctionKey) else { return nil }
|
||||
return String(Character(scalar))
|
||||
case "\t":
|
||||
return "\t"
|
||||
default:
|
||||
let lowered = key.lowercased()
|
||||
guard lowered.count == 1 else { return nil }
|
||||
return lowered
|
||||
}
|
||||
}
|
||||
|
||||
static func from(event: NSEvent) -> StoredShortcut? {
|
||||
guard let key = storedKey(from: event) else { return nil }
|
||||
|
||||
|
|
|
|||
|
|
@ -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,90 @@ struct OmnibarInlineCompletion: Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
private struct OmnibarAddressButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
OmnibarAddressButtonStyleBody(configuration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OmnibarAddressButtonStyleBody: View {
|
||||
let configuration: OmnibarAddressButtonStyle.Configuration
|
||||
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
@State private var isHovered = false
|
||||
|
||||
private var backgroundOpacity: Double {
|
||||
guard isEnabled else { return 0.0 }
|
||||
if configuration.isPressed { return 0.16 }
|
||||
if isHovered { return 0.08 }
|
||||
return 0.0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.primary.opacity(backgroundOpacity))
|
||||
)
|
||||
.onHover { hovering in
|
||||
isHovered = hovering
|
||||
}
|
||||
.animation(.easeOut(duration: 0.12), value: isHovered)
|
||||
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func cmuxFlatSymbolColorRendering() -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
self.symbolColorRenderingMode(.flat)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedBrowserChromeBackgroundColor(
|
||||
for colorScheme: ColorScheme,
|
||||
themeBackgroundColor: NSColor
|
||||
) -> NSColor {
|
||||
switch colorScheme {
|
||||
case .dark, .light:
|
||||
return themeBackgroundColor
|
||||
@unknown default:
|
||||
return themeBackgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedBrowserChromeColorScheme(
|
||||
for colorScheme: ColorScheme,
|
||||
themeBackgroundColor: NSColor
|
||||
) -> ColorScheme {
|
||||
let backgroundColor = resolvedBrowserChromeBackgroundColor(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: themeBackgroundColor
|
||||
)
|
||||
return backgroundColor.isLightColor ? .light : .dark
|
||||
}
|
||||
|
||||
func resolvedBrowserOmnibarPillBackgroundColor(
|
||||
for colorScheme: ColorScheme,
|
||||
themeBackgroundColor: NSColor
|
||||
) -> NSColor {
|
||||
let darkenMix: CGFloat
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
darkenMix = 0.04
|
||||
case .dark:
|
||||
darkenMix = 0.05
|
||||
@unknown default:
|
||||
darkenMix = 0.04
|
||||
}
|
||||
|
||||
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
|
||||
}
|
||||
|
||||
/// View for rendering a browser panel with address bar
|
||||
struct BrowserPanelView: View {
|
||||
@ObservedObject var panel: BrowserPanel
|
||||
|
|
@ -129,12 +213,14 @@ struct BrowserPanelView: View {
|
|||
let isVisibleInUI: Bool
|
||||
let portalPriority: Int
|
||||
let onRequestPanelFocus: () -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var omnibarState = OmnibarState()
|
||||
@State private var addressBarFocused: Bool = false
|
||||
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
|
||||
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
|
||||
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue
|
||||
@State private var suggestionTask: Task<Void, Never>?
|
||||
@State private var isLoadingRemoteSuggestions: Bool = false
|
||||
@State private var latestRemoteSuggestionQuery: String = ""
|
||||
|
|
@ -144,12 +230,16 @@ struct BrowserPanelView: View {
|
|||
@State private var omnibarHasMarkedText: Bool = false
|
||||
@State private var suppressNextFocusLostRevert: Bool = false
|
||||
@State private var focusFlashOpacity: Double = 0.0
|
||||
@State private var focusFlashFadeWorkItem: DispatchWorkItem?
|
||||
@State private var focusFlashAnimationGeneration: Int = 0
|
||||
@State private var omnibarPillFrame: CGRect = .zero
|
||||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
private let omnibarPillCornerRadius: CGFloat = 12
|
||||
@State private var isBrowserThemeMenuPresented = false
|
||||
// Keep this below half of the compact omnibar height so it reads as a squircle,
|
||||
// not a capsule.
|
||||
private let omnibarPillCornerRadius: CGFloat = 10
|
||||
private let addressBarButtonSize: CGFloat = 22
|
||||
private let addressBarButtonHitSize: CGFloat = 32
|
||||
private let addressBarButtonHitSize: CGFloat = 26
|
||||
private let addressBarVerticalPadding: CGFloat = 4
|
||||
private let devToolsButtonIconSize: CGFloat = 11
|
||||
|
||||
private var searchEngine: BrowserSearchEngine {
|
||||
|
|
@ -184,16 +274,41 @@ struct BrowserPanelView: View {
|
|||
BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
|
||||
}
|
||||
|
||||
private var browserThemeMode: BrowserThemeMode {
|
||||
BrowserThemeSettings.mode(for: browserThemeModeRaw)
|
||||
}
|
||||
|
||||
private var browserChromeBackgroundColor: NSColor {
|
||||
resolvedBrowserChromeBackgroundColor(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
private var browserChromeColorScheme: ColorScheme {
|
||||
resolvedBrowserChromeColorScheme(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
private var omnibarPillBackgroundColor: NSColor {
|
||||
resolvedBrowserOmnibarPillBackgroundColor(
|
||||
for: browserChromeColorScheme,
|
||||
themeBackgroundColor: browserChromeBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
addressBar
|
||||
webView
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3)
|
||||
.shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10)
|
||||
.padding(6)
|
||||
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
|
||||
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
|
||||
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
|
||||
.padding(FocusFlashPattern.ringInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
|
|
@ -213,8 +328,9 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
)
|
||||
.frame(width: omnibarPillFrame.width)
|
||||
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6)
|
||||
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3)
|
||||
.zIndex(1000)
|
||||
.environment(\.colorScheme, browserChromeColorScheme)
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "BrowserPanelViewSpace")
|
||||
|
|
@ -226,25 +342,32 @@ struct BrowserPanelView: View {
|
|||
guard let webView = note.object as? CmuxWebView else { return false }
|
||||
return webView === panel?.webView
|
||||
}) { _ in
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"isFocused=\(isFocused ? 1 : 0) " +
|
||||
"addressFocused=\(addressBarFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
onRequestPanelFocus()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .webViewMiddleClickedLink).filter { [weak panel] note in
|
||||
guard let webView = note.object as? CmuxWebView else { return false }
|
||||
return webView === panel?.webView
|
||||
}) { note in
|
||||
if let url = note.userInfo?["url"] as? URL {
|
||||
panel.openLinkInNewTab(url: url)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
|
||||
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
|
||||
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
|
||||
])
|
||||
let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard)
|
||||
if browserThemeModeRaw != resolvedThemeMode.rawValue {
|
||||
browserThemeModeRaw = resolvedThemeMode.rawValue
|
||||
}
|
||||
panel.refreshAppearanceDrivenColors()
|
||||
panel.setBrowserThemeMode(browserThemeMode)
|
||||
applyPendingAddressBarFocusRequestIfNeeded()
|
||||
syncURLFromPanel()
|
||||
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
|
||||
autoFocusOmnibarIfBlank()
|
||||
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
|
||||
BrowserHistoryStore.shared.loadIfNeeded()
|
||||
}
|
||||
.onChange(of: panel.focusFlashToken) { _ in
|
||||
|
|
@ -262,6 +385,16 @@ struct BrowserPanelView: View {
|
|||
addressBarFocused = false
|
||||
}
|
||||
}
|
||||
.onChange(of: browserThemeModeRaw) { _ in
|
||||
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
|
||||
if browserThemeModeRaw != normalizedMode.rawValue {
|
||||
browserThemeModeRaw = normalizedMode.rawValue
|
||||
}
|
||||
panel.setBrowserThemeMode(normalizedMode)
|
||||
}
|
||||
.onChange(of: colorScheme) { _ in
|
||||
panel.refreshAppearanceDrivenColors()
|
||||
}
|
||||
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
|
||||
applyPendingAddressBarFocusRequestIfNeeded()
|
||||
}
|
||||
|
|
@ -274,6 +407,7 @@ struct BrowserPanelView: View {
|
|||
hideSuggestions()
|
||||
addressBarFocused = false
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
|
||||
}
|
||||
.onChange(of: addressBarFocused) { focused in
|
||||
let urlString = panel.preferredURLStringForOmnibar() ?? ""
|
||||
|
|
@ -301,6 +435,7 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
inlineCompletion = nil
|
||||
}
|
||||
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
|
||||
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
|
||||
|
|
@ -332,13 +467,17 @@ struct BrowserPanelView: View {
|
|||
.accessibilityIdentifier("BrowserOmnibarPill")
|
||||
.accessibilityLabel("Browser omnibar")
|
||||
|
||||
developerToolsButton
|
||||
if !panel.isShowingNewTabPage {
|
||||
browserThemeModeButton
|
||||
developerToolsButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.padding(.vertical, addressBarVerticalPadding)
|
||||
.background(Color(nsColor: browserChromeBackgroundColor))
|
||||
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
|
||||
.zIndex(1)
|
||||
.environment(\.colorScheme, browserChromeColorScheme)
|
||||
}
|
||||
|
||||
private var addressBarButtonBar: some View {
|
||||
|
|
@ -354,7 +493,7 @@ struct BrowserPanelView: View {
|
|||
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(OmnibarAddressButtonStyle())
|
||||
.disabled(!panel.canGoBack)
|
||||
.opacity(panel.canGoBack ? 1.0 : 0.4)
|
||||
.help("Go Back")
|
||||
|
|
@ -370,7 +509,7 @@ struct BrowserPanelView: View {
|
|||
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(OmnibarAddressButtonStyle())
|
||||
.disabled(!panel.canGoForward)
|
||||
.opacity(panel.canGoForward ? 1.0 : 0.4)
|
||||
.help("Go Forward")
|
||||
|
|
@ -393,8 +532,20 @@ struct BrowserPanelView: View {
|
|||
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(OmnibarAddressButtonStyle())
|
||||
.help(panel.isLoading ? "Stop" : "Reload")
|
||||
|
||||
if panel.isDownloading {
|
||||
HStack(spacing: 4) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Downloading...")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.help("Download in progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -403,16 +554,74 @@ struct BrowserPanelView: View {
|
|||
openDevTools()
|
||||
}) {
|
||||
Image(systemName: devToolsIconOption.rawValue)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.cmuxFlatSymbolColorRendering()
|
||||
.font(.system(size: devToolsButtonIconSize, weight: .medium))
|
||||
.foregroundStyle(devToolsColorOption.color)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(OmnibarAddressButtonStyle())
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.help("Toggle Developer Tools")
|
||||
.help(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip("Toggle Developer Tools"))
|
||||
.accessibilityIdentifier("BrowserToggleDevToolsButton")
|
||||
}
|
||||
|
||||
private var browserThemeModeButton: some View {
|
||||
Button(action: {
|
||||
isBrowserThemeMenuPresented.toggle()
|
||||
}) {
|
||||
Image(systemName: browserThemeMode.iconName)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.cmuxFlatSymbolColorRendering()
|
||||
.font(.system(size: devToolsButtonIconSize, weight: .medium))
|
||||
.foregroundStyle(browserThemeModeIconColor)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
}
|
||||
.buttonStyle(OmnibarAddressButtonStyle())
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) {
|
||||
browserThemeModePopover
|
||||
}
|
||||
.help("Browser Theme: \(browserThemeMode.displayName)")
|
||||
.accessibilityIdentifier("BrowserThemeModeButton")
|
||||
}
|
||||
|
||||
private var browserThemeModePopover: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(BrowserThemeMode.allCases) { mode in
|
||||
Button {
|
||||
applyBrowserThemeModeSelection(mode)
|
||||
isBrowserThemeMenuPresented = false
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: mode == browserThemeMode ? "checkmark" : "circle")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.opacity(mode == browserThemeMode ? 1.0 : 0.0)
|
||||
.frame(width: 12, alignment: .center)
|
||||
Text(mode.displayName)
|
||||
.font(.system(size: 12))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 24)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(mode == browserThemeMode ? Color.primary.opacity(0.12) : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("BrowserThemeModeOption\(mode.rawValue.capitalized)")
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(minWidth: 128)
|
||||
}
|
||||
|
||||
private var browserThemeModeIconColor: Color {
|
||||
devToolsColorOption.color
|
||||
}
|
||||
|
||||
private var omnibarField: some View {
|
||||
let showSecureBadge = panel.currentURL?.scheme == "https"
|
||||
|
||||
|
|
@ -483,11 +692,11 @@ struct BrowserPanelView: View {
|
|||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
||||
.fill(Color(nsColor: .textBackgroundColor))
|
||||
.fill(Color(nsColor: omnibarPillBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
||||
.stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1)
|
||||
.stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.accessibilityElement(children: .contain)
|
||||
.background {
|
||||
|
|
@ -502,42 +711,77 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private var webView: some View {
|
||||
WebViewRepresentable(
|
||||
panel: panel,
|
||||
shouldAttachWebView: isVisibleInUI,
|
||||
shouldFocusWebView: isFocused && !addressBarFocused,
|
||||
isPanelFocused: isFocused,
|
||||
portalZPriority: portalPriority
|
||||
)
|
||||
// Keep the representable identity stable across bonsplit structural updates.
|
||||
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
|
||||
.id(panel.id)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
// Chrome-like behavior: clicking web content while editing the
|
||||
// omnibar should commit blur and revert transient edits.
|
||||
if addressBarFocused {
|
||||
addressBarFocused = false
|
||||
}
|
||||
})
|
||||
.zIndex(0)
|
||||
Group {
|
||||
if panel.shouldRenderWebView {
|
||||
WebViewRepresentable(
|
||||
panel: panel,
|
||||
shouldAttachWebView: isVisibleInUI,
|
||||
shouldFocusWebView: isFocused && !addressBarFocused,
|
||||
isPanelFocused: isFocused,
|
||||
portalZPriority: portalPriority
|
||||
)
|
||||
// Keep the representable identity stable across bonsplit structural updates.
|
||||
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
|
||||
.id(panel.id)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
// Chrome-like behavior: clicking web content while editing the
|
||||
// omnibar should commit blur and revert transient edits.
|
||||
if addressBarFocused {
|
||||
addressBarFocused = false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Color(nsColor: browserChromeBackgroundColor)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onRequestPanelFocus()
|
||||
if addressBarFocused {
|
||||
addressBarFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.zIndex(0)
|
||||
}
|
||||
|
||||
private func triggerFocusFlashAnimation() {
|
||||
focusFlashFadeWorkItem?.cancel()
|
||||
focusFlashFadeWorkItem = nil
|
||||
focusFlashAnimationGeneration &+= 1
|
||||
let generation = focusFlashAnimationGeneration
|
||||
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
|
||||
|
||||
withAnimation(.easeOut(duration: 0.08)) {
|
||||
focusFlashOpacity = 1.0
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem {
|
||||
withAnimation(.easeOut(duration: 0.35)) {
|
||||
focusFlashOpacity = 0.0
|
||||
for segment in FocusFlashPattern.segments {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
|
||||
guard focusFlashAnimationGeneration == generation else { return }
|
||||
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
|
||||
focusFlashOpacity = segment.targetOpacity
|
||||
}
|
||||
}
|
||||
}
|
||||
focusFlashFadeWorkItem = item
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: item)
|
||||
}
|
||||
|
||||
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
|
||||
switch curve {
|
||||
case .easeIn:
|
||||
return .easeIn(duration: duration)
|
||||
case .easeOut:
|
||||
return .easeOut(duration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncWebViewResponderPolicyWithViewState(reason: String) {
|
||||
guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
|
||||
let next = isFocused && !panel.shouldSuppressWebViewFocus()
|
||||
if cmuxWebView.allowsFirstResponderAcquisition != next {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"new=\(next ? 1 : 0) reason=\(reason)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
cmuxWebView.allowsFirstResponderAcquisition = next
|
||||
}
|
||||
|
||||
private func syncURLFromPanel() {
|
||||
|
|
@ -546,8 +790,32 @@ struct BrowserPanelView: View {
|
|||
applyOmnibarEffects(effects)
|
||||
}
|
||||
|
||||
private func isCommandPaletteVisibleForPanelWindow() -> Bool {
|
||||
guard let app = AppDelegate.shared else { return false }
|
||||
|
||||
if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let manager = app.tabManagerFor(tabId: panel.workspaceId),
|
||||
let windowId = app.windowId(for: manager),
|
||||
let window = app.mainWindow(for: windowId),
|
||||
app.isCommandPaletteVisible(for: window) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
|
||||
return true
|
||||
}
|
||||
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func applyPendingAddressBarFocusRequestIfNeeded() {
|
||||
guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else { return }
|
||||
guard lastHandledAddressBarFocusRequestId != requestId else { return }
|
||||
lastHandledAddressBarFocusRequestId = requestId
|
||||
panel.beginSuppressWebViewFocusForAddressBar()
|
||||
|
|
@ -575,6 +843,7 @@ struct BrowserPanelView: View {
|
|||
private func autoFocusOmnibarIfBlank() {
|
||||
guard isFocused else { return }
|
||||
guard !addressBarFocused else { return }
|
||||
guard !isCommandPaletteVisibleForPanelWindow() else { return }
|
||||
// If a test/automation explicitly focused WebKit, don't steal focus back.
|
||||
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
|
||||
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
|
||||
|
|
@ -592,6 +861,13 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func applyBrowserThemeModeSelection(_ mode: BrowserThemeMode) {
|
||||
if browserThemeModeRaw != mode.rawValue {
|
||||
browserThemeModeRaw = mode.rawValue
|
||||
}
|
||||
panel.setBrowserThemeMode(mode)
|
||||
}
|
||||
|
||||
private func handleOmnibarTap() {
|
||||
onRequestPanelFocus()
|
||||
guard !addressBarFocused else { return }
|
||||
|
|
@ -1942,6 +2218,13 @@ struct OmnibarSuggestion: Identifiable, Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
func browserOmnibarShouldReacquireFocusAfterEndEditing(
|
||||
suppressWebViewFocus: Bool,
|
||||
nextResponderIsOtherTextField: Bool
|
||||
) -> Bool {
|
||||
suppressWebViewFocus && !nextResponderIsOtherTextField
|
||||
}
|
||||
|
||||
private final class OmnibarNativeTextField: NSTextField {
|
||||
var onPointerDown: (() -> Void)?
|
||||
var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)?
|
||||
|
|
@ -2054,6 +2337,29 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool {
|
||||
guard let window, let field = parentField else { return false }
|
||||
let responder = window.firstResponder
|
||||
|
||||
if let editor = responder as? NSTextView,
|
||||
let delegateField = editor.delegate as? NSTextField {
|
||||
return delegateField !== field
|
||||
}
|
||||
|
||||
if let textField = responder as? NSTextField {
|
||||
return textField !== field
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
|
||||
return browserOmnibarShouldReacquireFocusAfterEndEditing(
|
||||
suppressWebViewFocus: parent.shouldSuppressWebViewFocus(),
|
||||
nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
|
||||
)
|
||||
}
|
||||
|
||||
func controlTextDidBeginEditing(_ obj: Notification) {
|
||||
if !parent.isFocused {
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -2066,15 +2372,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
|
||||
func controlTextDidEndEditing(_ obj: Notification) {
|
||||
if parent.isFocused {
|
||||
if parent.shouldSuppressWebViewFocus() {
|
||||
if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
|
||||
guard pendingFocusRequest != true else { return }
|
||||
pendingFocusRequest = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingFocusRequest = nil
|
||||
guard self.parent.isFocused else { return }
|
||||
guard self.parent.shouldSuppressWebViewFocus() else { return }
|
||||
guard let field = self.parentField, let window = field.window else { return }
|
||||
guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
|
||||
self.parent.onFieldLostFocus()
|
||||
return
|
||||
}
|
||||
// Check both the field itself AND its field editor (which becomes
|
||||
// the actual first responder when the text field is being edited).
|
||||
let fr = window.firstResponder
|
||||
|
|
@ -2387,11 +2696,12 @@ private struct OmnibarSuggestionsView: View {
|
|||
let searchSuggestionsEnabled: Bool
|
||||
let onCommit: (OmnibarSuggestion) -> Void
|
||||
let onHighlight: (Int) -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
// Keep radii below the smallest rendered heights so corners don't get
|
||||
// auto-clamped and visually change as popup height changes.
|
||||
private let popupCornerRadius: CGFloat = 16
|
||||
private let rowHighlightCornerRadius: CGFloat = 12
|
||||
// Keep radii below half of the smallest rendered heights so this keeps a
|
||||
// squircle silhouette instead of auto-clamping into a capsule.
|
||||
private let popupCornerRadius: CGFloat = 12
|
||||
private let rowHighlightCornerRadius: CGFloat = 9
|
||||
private let singleLineRowHeight: CGFloat = 24
|
||||
private let rowSpacing: CGFloat = 1
|
||||
private let topInset: CGFloat = 3
|
||||
|
|
@ -2444,6 +2754,101 @@ private struct OmnibarSuggestionsView: View {
|
|||
contentHeight > maxPopupHeight
|
||||
}
|
||||
|
||||
private var listTextColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color(nsColor: .labelColor)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.9)
|
||||
@unknown default:
|
||||
return Color(nsColor: .labelColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeTextColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.72)
|
||||
@unknown default:
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeBackgroundColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.06)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.08)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
private var rowHighlightColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.07)
|
||||
case .dark:
|
||||
return Color.white.opacity(0.12)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.07)
|
||||
}
|
||||
}
|
||||
|
||||
private var popupOverlayGradientColors: [Color] {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return [
|
||||
Color.white.opacity(0.55),
|
||||
Color.white.opacity(0.2),
|
||||
]
|
||||
case .dark:
|
||||
return [
|
||||
Color.black.opacity(0.26),
|
||||
Color.black.opacity(0.14),
|
||||
]
|
||||
@unknown default:
|
||||
return [
|
||||
Color.white.opacity(0.55),
|
||||
Color.white.opacity(0.2),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private var popupBorderGradientColors: [Color] {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return [
|
||||
Color.white.opacity(0.65),
|
||||
Color.black.opacity(0.12),
|
||||
]
|
||||
case .dark:
|
||||
return [
|
||||
Color.white.opacity(0.22),
|
||||
Color.white.opacity(0.06),
|
||||
]
|
||||
@unknown default:
|
||||
return [
|
||||
Color.white.opacity(0.65),
|
||||
Color.black.opacity(0.12),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private var popupShadowColor: Color {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
return Color.black.opacity(0.18)
|
||||
case .dark:
|
||||
return Color.black.opacity(0.45)
|
||||
@unknown default:
|
||||
return Color.black.opacity(0.18)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rowsView: some View {
|
||||
VStack(spacing: rowSpacing) {
|
||||
|
|
@ -2457,18 +2862,18 @@ private struct OmnibarSuggestionsView: View {
|
|||
HStack(spacing: 6) {
|
||||
Text(item.listText)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color.white.opacity(0.9))
|
||||
.foregroundStyle(listTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if let badge = item.trailingBadgeText {
|
||||
Text(badge)
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(Color.white.opacity(0.72))
|
||||
.foregroundStyle(badgeTextColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.fill(badgeBackgroundColor)
|
||||
)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
|
@ -2484,7 +2889,7 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
|
||||
.fill(
|
||||
idx == selectedIndex
|
||||
? Color.white.opacity(0.12)
|
||||
? rowHighlightColor
|
||||
: Color.clear
|
||||
)
|
||||
)
|
||||
|
|
@ -2539,10 +2944,7 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.26),
|
||||
Color.black.opacity(0.14),
|
||||
],
|
||||
colors: popupOverlayGradientColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
|
@ -2553,18 +2955,16 @@ private struct OmnibarSuggestionsView: View {
|
|||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.22),
|
||||
Color.white.opacity(0.06),
|
||||
],
|
||||
colors: popupBorderGradientColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 20, y: 10)
|
||||
.contentShape(Rectangle())
|
||||
.clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
|
||||
.shadow(color: popupShadowColor, radius: 20, y: 10)
|
||||
.contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityRespondsToUserInteraction(true)
|
||||
.accessibilityIdentifier("BrowserOmnibarSuggestions")
|
||||
|
|
@ -2621,6 +3021,27 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
super.setFrameSize(newSize)
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
if shouldPassThroughToSidebarResizer(at: point) {
|
||||
return nil
|
||||
}
|
||||
return super.hitTest(point)
|
||||
}
|
||||
|
||||
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
|
||||
// Pass through a narrow leading-edge band so the shared sidebar divider
|
||||
// handle can receive hover/click even when WKWebView is attached here.
|
||||
// Keeping this deterministic avoids flicker from dynamic left-edge scans.
|
||||
guard point.x >= 0, point.x <= SidebarResizeInteraction.hitWidthPerSide else {
|
||||
return false
|
||||
}
|
||||
guard let window, let contentView = window.contentView else {
|
||||
return false
|
||||
}
|
||||
let hostRectInContent = contentView.convert(bounds, from: self)
|
||||
return hostRectInContent.minX > 1
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -2842,6 +3263,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator: Coordinator,
|
||||
generation: Int
|
||||
) {
|
||||
let retryInterval: TimeInterval = 1.0 / 60.0
|
||||
// Don't schedule multiple overlapping retries.
|
||||
guard coordinator.attachRetryWorkItem == nil else { return }
|
||||
|
||||
|
|
@ -2874,7 +3296,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// Be generous here: bonsplit structural updates can keep a representable
|
||||
// container off-window longer than a few seconds under load.
|
||||
if coordinator.attachRetryCount < 400 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
|
||||
scheduleAttachRetry(
|
||||
webView,
|
||||
panel: panel,
|
||||
|
|
@ -2911,13 +3333,18 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
coordinator.attachRetryWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
let webView = panel.webView
|
||||
context.coordinator.panel = panel
|
||||
context.coordinator.webView = webView
|
||||
Self.applyWebViewFirstResponderPolicy(
|
||||
panel: panel,
|
||||
webView: webView,
|
||||
isPanelFocused: isPanelFocused
|
||||
)
|
||||
|
||||
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
|
||||
if shouldUseWindowPortal {
|
||||
|
|
@ -3165,6 +3592,26 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private static func applyWebViewFirstResponderPolicy(
|
||||
panel: BrowserPanel,
|
||||
webView: WKWebView,
|
||||
isPanelFocused: Bool
|
||||
) {
|
||||
guard let cmuxWebView = webView as? CmuxWebView else { return }
|
||||
let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
|
||||
if cmuxWebView.allowsFirstResponderAcquisition != next {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " +
|
||||
"suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
cmuxWebView.allowsFirstResponderAcquisition = next
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||||
coordinator.attachRetryWorkItem?.cancel()
|
||||
coordinator.attachRetryWorkItem = nil
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import ObjectiveC
|
||||
import WebKit
|
||||
|
||||
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
|
||||
|
|
@ -6,14 +8,124 @@ import WebKit
|
|||
/// key equivalents first so app-level shortcuts continue to work when WebKit is
|
||||
/// the first responder.
|
||||
final class CmuxWebView: WKWebView {
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
|
||||
// route it through app/menu key equivalents, which can trigger unintended actions.
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
|
||||
// Some sites/WebKit paths report middle-click link activations as
|
||||
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
|
||||
// middle-click so navigation delegates can recover intent reliably.
|
||||
private struct MiddleClickIntent {
|
||||
let webViewID: ObjectIdentifier
|
||||
let uptime: TimeInterval
|
||||
}
|
||||
|
||||
private static var lastMiddleClickIntent: MiddleClickIntent?
|
||||
private static let middleClickIntentMaxAge: TimeInterval = 0.8
|
||||
|
||||
static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool {
|
||||
guard let webView = webView as? CmuxWebView else { return false }
|
||||
guard let intent = lastMiddleClickIntent else { return false }
|
||||
|
||||
let age = ProcessInfo.processInfo.systemUptime - intent.uptime
|
||||
if age > middleClickIntentMaxAge {
|
||||
lastMiddleClickIntent = nil
|
||||
return false
|
||||
}
|
||||
|
||||
return intent.webViewID == ObjectIdentifier(webView)
|
||||
}
|
||||
|
||||
private static func recordMiddleClickIntent(for webView: CmuxWebView) {
|
||||
lastMiddleClickIntent = MiddleClickIntent(
|
||||
webViewID: ObjectIdentifier(webView),
|
||||
uptime: ProcessInfo.processInfo.systemUptime
|
||||
)
|
||||
}
|
||||
|
||||
private final class ContextMenuFallbackBox: NSObject {
|
||||
weak var target: AnyObject?
|
||||
let action: Selector?
|
||||
|
||||
init(target: AnyObject?, action: Selector?) {
|
||||
self.target = target
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
private static var contextMenuFallbackKey: UInt8 = 0
|
||||
|
||||
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
|
||||
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
|
||||
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
|
||||
/// Guard against background panes stealing first responder (e.g. page autofocus).
|
||||
/// BrowserPanelView updates this as pane focus state changes.
|
||||
var allowsFirstResponderAcquisition: Bool = true
|
||||
private var pointerFocusAllowanceDepth: Int = 0
|
||||
var allowsFirstResponderAcquisitionEffective: Bool {
|
||||
allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0
|
||||
}
|
||||
var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth }
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
guard allowsFirstResponderAcquisitionEffective else {
|
||||
#if DEBUG
|
||||
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
||||
dlog(
|
||||
"browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " +
|
||||
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
let result = super.becomeFirstResponder()
|
||||
if result {
|
||||
NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self)
|
||||
}
|
||||
#if DEBUG
|
||||
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
||||
dlog(
|
||||
"browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " +
|
||||
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
|
||||
)
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
|
||||
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
|
||||
/// (mouse click into this webview) while keeping background autofocus blocked.
|
||||
func withPointerFocusAllowance(_ body: () -> Void) {
|
||||
pointerFocusAllowanceDepth += 1
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " +
|
||||
"depth=\(pointerFocusAllowanceDepth)"
|
||||
)
|
||||
#endif
|
||||
defer {
|
||||
pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " +
|
||||
"depth=\(pointerFocusAllowanceDepth)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
body()
|
||||
}
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
if event.keyCode == 36 || event.keyCode == 76 {
|
||||
// Always bypass app/menu key-equivalent routing for Return/Enter so WebKit
|
||||
// receives the keyDown path used by form submission handlers.
|
||||
return false
|
||||
}
|
||||
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
// Menu/app shortcut routing is only needed for Command equivalents
|
||||
// (New Tab, Close Tab, tab switching, split commands, etc).
|
||||
guard flags.contains(.command) else {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
|
||||
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
return true
|
||||
|
|
@ -46,20 +158,48 @@ final class CmuxWebView: WKWebView {
|
|||
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
|
||||
// bonsplit focus tracks which pane the user clicked in.
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let windowNumber = window?.windowNumber ?? -1
|
||||
let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
dlog(
|
||||
"browser.focus.mouseDown web=\(ObjectIdentifier(self)) " +
|
||||
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
|
||||
"pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)"
|
||||
)
|
||||
#endif
|
||||
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
|
||||
super.mouseDown(with: event)
|
||||
withPointerFocusAllowance {
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mouse back/forward buttons & middle-click
|
||||
// MARK: - Mouse back/forward buttons
|
||||
|
||||
override func otherMouseDown(with event: NSEvent) {
|
||||
if event.buttonNumber == 2 {
|
||||
Self.recordMiddleClickIntent(for: self)
|
||||
}
|
||||
#if DEBUG
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
|
||||
dlog(
|
||||
"browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
|
||||
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
|
||||
)
|
||||
#endif
|
||||
// Button 3 = back, button 4 = forward (multi-button mice like Logitech).
|
||||
// Consume the event so WebKit doesn't handle it.
|
||||
switch event.buttonNumber {
|
||||
case 3:
|
||||
#if DEBUG
|
||||
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)")
|
||||
#endif
|
||||
goBack()
|
||||
return
|
||||
case 4:
|
||||
#if DEBUG
|
||||
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)")
|
||||
#endif
|
||||
goForward()
|
||||
return
|
||||
default:
|
||||
|
|
@ -69,25 +209,23 @@ final class CmuxWebView: WKWebView {
|
|||
}
|
||||
|
||||
override func otherMouseUp(with event: NSEvent) {
|
||||
// Middle-click (button 2) on a link opens it in a new tab.
|
||||
if event.buttonNumber == 2 {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
findLinkAtPoint(point) { [weak self] url in
|
||||
guard let self, let url else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: .webViewMiddleClickedLink,
|
||||
object: self,
|
||||
userInfo: ["url": url]
|
||||
)
|
||||
}
|
||||
return
|
||||
Self.recordMiddleClickIntent(for: self)
|
||||
}
|
||||
#if DEBUG
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
|
||||
dlog(
|
||||
"browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
|
||||
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
|
||||
)
|
||||
#endif
|
||||
super.otherMouseUp(with: event)
|
||||
}
|
||||
|
||||
/// Use JavaScript to find the nearest anchor element at the given view-local point.
|
||||
/// Finds the nearest anchor element at a given view-local point.
|
||||
/// Used as a context-menu download fallback.
|
||||
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
|
||||
// WKWebView's coordinate system is flipped (origin top-left for web content).
|
||||
let flippedY = bounds.height - point.y
|
||||
let js = """
|
||||
(() => {
|
||||
|
|
@ -109,6 +247,327 @@ final class CmuxWebView: WKWebView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Context menu download support
|
||||
|
||||
/// The last context-menu point in view coordinates.
|
||||
private var lastContextMenuPoint: NSPoint = .zero
|
||||
/// Saved native WebKit action for "Download Image".
|
||||
private var fallbackDownloadImageTarget: AnyObject?
|
||||
private var fallbackDownloadImageAction: Selector?
|
||||
/// Saved native WebKit action for "Download Linked File".
|
||||
private var fallbackDownloadLinkedFileTarget: AnyObject?
|
||||
private var fallbackDownloadLinkedFileAction: Selector?
|
||||
|
||||
private func isDownloadableScheme(_ url: URL) -> Bool {
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
return scheme == "http" || scheme == "https" || scheme == "file"
|
||||
}
|
||||
|
||||
private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool {
|
||||
guard target === self else { return false }
|
||||
return action == #selector(contextMenuDownloadImage(_:))
|
||||
|| action == #selector(contextMenuDownloadLinkedFile(_:))
|
||||
}
|
||||
|
||||
private func resolveGoogleRedirectURL(_ url: URL) -> URL? {
|
||||
guard let host = url.host?.lowercased(), host.contains("google.") else { return nil }
|
||||
guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let queryItems = comps.queryItems else { return nil }
|
||||
let map = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name.lowercased(), $0.value ?? "") })
|
||||
let candidates = ["imgurl", "mediaurl", "url", "q"]
|
||||
for key in candidates {
|
||||
guard let raw = map[key], !raw.isEmpty,
|
||||
let decoded = raw.removingPercentEncoding ?? raw as String?,
|
||||
let candidate = URL(string: decoded),
|
||||
isDownloadableScheme(candidate) else {
|
||||
continue
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
// Some links are wrapped as /url?...
|
||||
if comps.path.lowercased() == "/url" {
|
||||
for key in ["url", "q"] {
|
||||
if let raw = map[key], let candidate = URL(string: raw), isDownloadableScheme(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizedLinkedDownloadURL(_ url: URL) -> URL {
|
||||
resolveGoogleRedirectURL(url) ?? url
|
||||
}
|
||||
|
||||
private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) {
|
||||
let target = item.target as AnyObject?
|
||||
let action = item.action
|
||||
if isOurDownloadMenuAction(target: target, action: action) {
|
||||
return
|
||||
}
|
||||
let box = ContextMenuFallbackBox(target: target, action: action)
|
||||
objc_setAssociatedObject(
|
||||
item,
|
||||
&Self.contextMenuFallbackKey,
|
||||
box,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
|
||||
private func fallbackFromSender(
|
||||
_ sender: Any?,
|
||||
defaultAction: Selector?,
|
||||
defaultTarget: AnyObject?
|
||||
) -> (action: Selector?, target: AnyObject?) {
|
||||
if let item = sender as? NSMenuItem,
|
||||
let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
|
||||
return (box.action, box.target)
|
||||
}
|
||||
return (defaultAction, defaultTarget)
|
||||
}
|
||||
|
||||
/// Resolve the topmost image URL near a point, accounting for overlay layers.
|
||||
private func findImageURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
|
||||
let flippedY = bounds.height - point.y
|
||||
let js = """
|
||||
(() => {
|
||||
const nodes = document.elementsFromPoint(\(point.x), \(flippedY));
|
||||
for (const start of nodes) {
|
||||
let elChain = [];
|
||||
let seen = new Set();
|
||||
let walk = (node) => {
|
||||
let chain = [];
|
||||
let localSeen = new Set();
|
||||
let visit = (n) => {
|
||||
while (n && !localSeen.has(n)) {
|
||||
localSeen.add(n);
|
||||
chain.push(n);
|
||||
n = n.parentElement;
|
||||
}
|
||||
};
|
||||
visit(node);
|
||||
if (node && node.tagName === 'PICTURE') {
|
||||
const img = node.querySelector('img');
|
||||
if (img) visit(img);
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
for (const el of walk(start)) {
|
||||
if (!seen.has(el)) {
|
||||
seen.add(el);
|
||||
elChain.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of elChain) {
|
||||
if (el.tagName === 'IMG') {
|
||||
if (el.currentSrc) return el.currentSrc;
|
||||
if (el.src) return el.src;
|
||||
}
|
||||
if (el.tagName === 'PICTURE') {
|
||||
const img = el.querySelector('img');
|
||||
if (img) {
|
||||
if (img.currentSrc) return img.currentSrc;
|
||||
if (img.src) return img.src;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
"""
|
||||
evaluateJavaScript(js) { result, _ in
|
||||
guard let src = result as? String, !src.isEmpty,
|
||||
let url = URL(string: src) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the topmost link URL near a point, accounting for overlay layers.
|
||||
private func findLinkURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
|
||||
let flippedY = bounds.height - point.y
|
||||
let js = """
|
||||
(() => {
|
||||
const nodes = document.elementsFromPoint(\(point.x), \(flippedY));
|
||||
for (const start of nodes) {
|
||||
let el = start;
|
||||
let seen = new Set();
|
||||
let cur = (() => {
|
||||
let n = start;
|
||||
return n;
|
||||
})();
|
||||
let walk = (node) => {
|
||||
let chain = [];
|
||||
while (node && !seen.has(node)) {
|
||||
seen.add(node);
|
||||
chain.push(node);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
for (const n of walk(cur)) {
|
||||
if (n.tagName === 'A' && n.href) return n.href;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
"""
|
||||
evaluateJavaScript(js) { result, _ in
|
||||
guard let href = result as? String, !href.isEmpty,
|
||||
let url = URL(string: href) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(url)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveContextMenuLinkURL(at point: NSPoint, completion: @escaping (URL?) -> Void) {
|
||||
if let contextMenuLinkURLProvider {
|
||||
contextMenuLinkURLProvider(self, point, completion)
|
||||
return
|
||||
}
|
||||
findLinkURLAtPoint(point, completion: completion)
|
||||
}
|
||||
|
||||
private func canOpenInDefaultBrowser(_ url: URL) -> Bool {
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
return scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
private func openContextMenuLinkInDefaultBrowser(_ url: URL) {
|
||||
if let contextMenuDefaultBrowserOpener {
|
||||
_ = contextMenuDefaultBrowserOpener(url)
|
||||
return
|
||||
}
|
||||
_ = NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) {
|
||||
guard let action else { return }
|
||||
// Guard against accidental self-recursion if fallback gets overwritten.
|
||||
if target === self,
|
||||
action == #selector(contextMenuDownloadImage(_:))
|
||||
|| action == #selector(contextMenuDownloadLinkedFile(_:)) {
|
||||
NSLog("CmuxWebView context fallback skipped (recursive self action)")
|
||||
return
|
||||
}
|
||||
_ = NSApp.sendAction(action, to: target, from: sender)
|
||||
}
|
||||
|
||||
private func notifyContextMenuDownloadState(_ downloading: Bool) {
|
||||
if Thread.isMainThread {
|
||||
onContextMenuDownloadStateChanged?(downloading)
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onContextMenuDownloadStateChanged?(downloading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadURLViaSession(
|
||||
_ url: URL,
|
||||
suggestedFilename: String?,
|
||||
sender: Any?,
|
||||
fallbackAction: Selector?,
|
||||
fallbackTarget: AnyObject?
|
||||
) {
|
||||
guard isDownloadableScheme(url) else {
|
||||
runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
|
||||
return
|
||||
}
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
notifyContextMenuDownloadState(true)
|
||||
|
||||
if scheme == "file" {
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent)
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.nameFieldStringValue = saveName
|
||||
savePanel.canCreateDirectories = true
|
||||
savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
// Download is already complete; we're now waiting for user save choice.
|
||||
self.notifyContextMenuDownloadState(false)
|
||||
savePanel.begin { result in
|
||||
guard result == .OK, let destURL = savePanel.url else { return }
|
||||
try? data.write(to: destURL, options: .atomic)
|
||||
}
|
||||
} catch {
|
||||
self.notifyContextMenuDownloadState(false)
|
||||
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let cookieStore = configuration.websiteDataStore.httpCookieStore
|
||||
cookieStore.getAllCookies { cookies in
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
let cookieHeaders = HTTPCookie.requestHeaderFields(with: cookies)
|
||||
for (key, value) in cookieHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
if let referer = self.url?.absoluteString, !referer.isEmpty {
|
||||
request.setValue(referer, forHTTPHeaderField: "Referer")
|
||||
}
|
||||
if let ua = self.customUserAgent, !ua.isEmpty {
|
||||
request.setValue(ua, forHTTPHeaderField: "User-Agent")
|
||||
}
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
guard let data, error == nil else {
|
||||
self.notifyContextMenuDownloadState(false)
|
||||
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
|
||||
return
|
||||
}
|
||||
let filenameCandidate = suggestedFilename
|
||||
?? response?.suggestedFilename
|
||||
?? url.lastPathComponent
|
||||
let saveName = filenameCandidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "download" : filenameCandidate
|
||||
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.nameFieldStringValue = saveName
|
||||
savePanel.canCreateDirectories = true
|
||||
savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
// Download is already complete; we're now waiting for user save choice.
|
||||
self.notifyContextMenuDownloadState(false)
|
||||
savePanel.begin { result in
|
||||
guard result == .OK, let destURL = savePanel.url else { return }
|
||||
do {
|
||||
try data.write(to: destURL, options: .atomic)
|
||||
} catch {
|
||||
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func startContextMenuDownload(
|
||||
_ url: URL,
|
||||
sender: Any?,
|
||||
fallbackAction: Selector?,
|
||||
fallbackTarget: AnyObject?
|
||||
) {
|
||||
NSLog("CmuxWebView context download start: %@", url.absoluteString)
|
||||
downloadURLViaSession(
|
||||
url,
|
||||
suggestedFilename: nil,
|
||||
sender: sender,
|
||||
fallbackAction: fallbackAction,
|
||||
fallbackTarget: fallbackTarget
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Drag-and-drop passthrough
|
||||
|
||||
// WKWebView inherently calls registerForDraggedTypes with public.text (and others).
|
||||
|
|
@ -136,8 +595,23 @@ final class CmuxWebView: WKWebView {
|
|||
|
||||
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
|
||||
super.willOpenMenu(menu, with: event)
|
||||
lastContextMenuPoint = convert(event.locationInWindow, from: nil)
|
||||
var openLinkInsertionIndex: Int?
|
||||
var hasDefaultBrowserOpenLinkItem = false
|
||||
|
||||
for (index, item) in menu.items.enumerated() {
|
||||
if !hasDefaultBrowserOpenLinkItem,
|
||||
(item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:))
|
||||
|| item.title == "Open Link in Default Browser") {
|
||||
hasDefaultBrowserOpenLinkItem = true
|
||||
}
|
||||
|
||||
if openLinkInsertionIndex == nil,
|
||||
(item.identifier?.rawValue == "WKMenuItemIdentifierOpenLink"
|
||||
|| item.title == "Open Link") {
|
||||
openLinkInsertionIndex = index + 1
|
||||
}
|
||||
|
||||
for item in menu.items {
|
||||
// Rename "Open Link in New Window" to "Open Link in New Tab".
|
||||
// The UIDelegate's createWebViewWith already handles the action
|
||||
// by opening the link as a new surface in the same pane.
|
||||
|
|
@ -145,6 +619,183 @@ final class CmuxWebView: WKWebView {
|
|||
|| item.title.contains("Open Link in New Window") {
|
||||
item.title = "Open Link in New Tab"
|
||||
}
|
||||
|
||||
if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage"
|
||||
|| item.title == "Download Image" {
|
||||
NSLog("CmuxWebView context menu hook: download image")
|
||||
captureFallbackForMenuItemIfNeeded(item)
|
||||
// Keep global fallback as a secondary safety net.
|
||||
if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
|
||||
fallbackDownloadImageTarget = box.target
|
||||
fallbackDownloadImageAction = box.action
|
||||
} else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) {
|
||||
fallbackDownloadImageTarget = item.target as AnyObject?
|
||||
fallbackDownloadImageAction = item.action
|
||||
}
|
||||
item.target = self
|
||||
item.action = #selector(contextMenuDownloadImage(_:))
|
||||
}
|
||||
|
||||
if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile"
|
||||
|| item.title == "Download Linked File" {
|
||||
NSLog("CmuxWebView context menu hook: download linked file")
|
||||
captureFallbackForMenuItemIfNeeded(item)
|
||||
// Keep global fallback as a secondary safety net.
|
||||
if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
|
||||
fallbackDownloadLinkedFileTarget = box.target
|
||||
fallbackDownloadLinkedFileAction = box.action
|
||||
} else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) {
|
||||
fallbackDownloadLinkedFileTarget = item.target as AnyObject?
|
||||
fallbackDownloadLinkedFileAction = item.action
|
||||
}
|
||||
item.target = self
|
||||
item.action = #selector(contextMenuDownloadLinkedFile(_:))
|
||||
}
|
||||
}
|
||||
|
||||
if let openLinkInsertionIndex, !hasDefaultBrowserOpenLinkItem {
|
||||
let item = NSMenuItem(
|
||||
title: "Open Link in Default Browser",
|
||||
action: #selector(contextMenuOpenLinkInDefaultBrowser(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
item.target = self
|
||||
menu.insertItem(item, at: min(openLinkInsertionIndex, menu.items.count))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func contextMenuOpenLinkInDefaultBrowser(_ sender: Any?) {
|
||||
_ = sender
|
||||
let point = lastContextMenuPoint
|
||||
resolveContextMenuLinkURL(at: point) { [weak self] url in
|
||||
guard let self, let url, self.canOpenInDefaultBrowser(url) else { return }
|
||||
self.openContextMenuLinkInDefaultBrowser(url)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func contextMenuDownloadImage(_ sender: Any?) {
|
||||
let point = lastContextMenuPoint
|
||||
let fallback = fallbackFromSender(
|
||||
sender,
|
||||
defaultAction: fallbackDownloadImageAction,
|
||||
defaultTarget: fallbackDownloadImageTarget
|
||||
)
|
||||
findImageURLAtPoint(point) { [weak self] url in
|
||||
guard let self else { return }
|
||||
if let url {
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
if scheme == "http" || scheme == "https" || scheme == "file" {
|
||||
NSLog("CmuxWebView context download image URL: %@", url.absoluteString)
|
||||
self.startContextMenuDownload(
|
||||
url,
|
||||
sender: sender,
|
||||
fallbackAction: fallback.action,
|
||||
fallbackTarget: fallback.target
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Google Images and similar sites often expose blob:/data: image URLs.
|
||||
// If image URL is not directly downloadable, fall back to the nearby link URL.
|
||||
self.findLinkURLAtPoint(point) { linkURL in
|
||||
guard let linkURL else {
|
||||
NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action")
|
||||
self.runContextMenuFallback(
|
||||
action: fallback.action,
|
||||
target: fallback.target,
|
||||
sender: sender
|
||||
)
|
||||
return
|
||||
}
|
||||
let linkScheme = linkURL.scheme?.lowercased() ?? ""
|
||||
guard linkScheme == "http" || linkScheme == "https" || linkScheme == "file" else {
|
||||
NSLog("CmuxWebView context download image: link URL not downloadable (%@), using fallback action", linkURL.absoluteString)
|
||||
self.runContextMenuFallback(
|
||||
action: fallback.action,
|
||||
target: fallback.target,
|
||||
sender: sender
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString)
|
||||
self.startContextMenuDownload(
|
||||
linkURL,
|
||||
sender: sender,
|
||||
fallbackAction: fallback.action,
|
||||
fallbackTarget: fallback.target
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func contextMenuDownloadLinkedFile(_ sender: Any?) {
|
||||
let point = lastContextMenuPoint
|
||||
let fallback = fallbackFromSender(
|
||||
sender,
|
||||
defaultAction: fallbackDownloadLinkedFileAction,
|
||||
defaultTarget: fallbackDownloadLinkedFileTarget
|
||||
)
|
||||
findLinkURLAtPoint(point) { [weak self] url in
|
||||
guard let self else { return }
|
||||
if let url {
|
||||
let normalized = self.normalizedLinkedDownloadURL(url)
|
||||
if self.isDownloadableScheme(normalized) {
|
||||
NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString)
|
||||
self.startContextMenuDownload(
|
||||
normalized,
|
||||
sender: sender,
|
||||
fallbackAction: fallback.action,
|
||||
fallbackTarget: fallback.target
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 1: image URL under cursor (useful on image-heavy result pages).
|
||||
self.findImageURLAtPoint(point) { imageURL in
|
||||
if let imageURL, self.isDownloadableScheme(imageURL) {
|
||||
NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString)
|
||||
self.startContextMenuDownload(
|
||||
imageURL,
|
||||
sender: sender,
|
||||
fallbackAction: fallback.action,
|
||||
fallbackTarget: fallback.target
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback 2: simpler nearest-anchor lookup.
|
||||
self.findLinkAtPoint(point) { fallbackURL in
|
||||
guard let fallbackURL else {
|
||||
NSLog("CmuxWebView context download linked file: URL nil, using fallback action")
|
||||
self.runContextMenuFallback(
|
||||
action: fallback.action,
|
||||
target: fallback.target,
|
||||
sender: sender
|
||||
)
|
||||
return
|
||||
}
|
||||
let normalized = self.normalizedLinkedDownloadURL(fallbackURL)
|
||||
guard self.isDownloadableScheme(normalized) else {
|
||||
NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString)
|
||||
self.runContextMenuFallback(
|
||||
action: fallback.action,
|
||||
target: fallback.target,
|
||||
sender: sender
|
||||
)
|
||||
return
|
||||
}
|
||||
NSLog("CmuxWebView context download linked file fallback URL: %@ (normalized=%@)", fallbackURL.absoluteString, normalized.absoluteString)
|
||||
self.startContextMenuDownload(
|
||||
normalized,
|
||||
sender: sender,
|
||||
fallbackAction: fallback.action,
|
||||
fallbackTarget: fallback.target
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ final class PostHogAnalytics {
|
|||
|
||||
PostHogSDK.shared.setup(config)
|
||||
|
||||
// Tag every event so PostHog can distinguish desktop from web.
|
||||
PostHogSDK.shared.register(["platform": "cmuxterm"])
|
||||
// Tag every event so PostHog can distinguish desktop from web and
|
||||
// break events down by released app version/build.
|
||||
PostHogSDK.shared.register(Self.superProperties(infoDictionary: Bundle.main.infoDictionary ?? [:]))
|
||||
|
||||
// The SDK automatically generates and persists an anonymous distinct ID.
|
||||
|
||||
|
|
@ -68,10 +69,14 @@ final class PostHogAnalytics {
|
|||
|
||||
defaults.set(today, forKey: lastActiveDayUTCKey)
|
||||
|
||||
PostHogSDK.shared.capture("cmux_daily_active", properties: [
|
||||
"day_utc": today,
|
||||
"reason": reason,
|
||||
])
|
||||
PostHogSDK.shared.capture(
|
||||
"cmux_daily_active",
|
||||
properties: Self.dailyActiveProperties(
|
||||
dayUTC: today,
|
||||
reason: reason,
|
||||
infoDictionary: Bundle.main.infoDictionary ?? [:]
|
||||
)
|
||||
)
|
||||
|
||||
// For DAU we care more about delivery than batching.
|
||||
PostHogSDK.shared.flush()
|
||||
|
|
@ -90,4 +95,34 @@ final class PostHogAnalytics {
|
|||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] {
|
||||
var properties: [String: Any] = ["platform": "cmuxterm"]
|
||||
properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new }
|
||||
return properties
|
||||
}
|
||||
|
||||
nonisolated static func dailyActiveProperties(
|
||||
dayUTC: String,
|
||||
reason: String,
|
||||
infoDictionary: [String: Any]
|
||||
) -> [String: Any] {
|
||||
var properties: [String: Any] = [
|
||||
"day_utc": dayUTC,
|
||||
"reason": reason,
|
||||
]
|
||||
properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new }
|
||||
return properties
|
||||
}
|
||||
|
||||
nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] {
|
||||
var properties: [String: Any] = [:]
|
||||
if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty {
|
||||
properties["app_version"] = value
|
||||
}
|
||||
if let value = infoDictionary["CFBundleVersion"] as? String, !value.isEmpty {
|
||||
properties["app_build"] = value
|
||||
}
|
||||
return properties
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
Sources/SentryHelper.swift
Normal file
9
Sources/SentryHelper.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import Sentry
|
||||
|
||||
/// Add a Sentry breadcrumb for user-action context in hang/crash reports.
|
||||
func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) {
|
||||
let crumb = Breadcrumb(level: .info, category: category)
|
||||
crumb.message = message
|
||||
crumb.data = data
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
}
|
||||
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,17 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
enum SocketControlMode: String, CaseIterable, Identifiable {
|
||||
case off
|
||||
case cmuxOnly
|
||||
/// Allow any local process to connect (no ancestry check).
|
||||
/// Only accessible via CMUX_SOCKET_MODE=allowAll env var — not shown in the UI.
|
||||
case automation
|
||||
case password
|
||||
/// Full open access (all local users/processes) with no ancestry or password gate.
|
||||
case allowAll
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Cases shown in the Settings UI. `allowAll` is intentionally excluded.
|
||||
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly] }
|
||||
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
|
|
@ -18,8 +19,12 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
|
|||
return "Off"
|
||||
case .cmuxOnly:
|
||||
return "cmux processes only"
|
||||
case .automation:
|
||||
return "Automation mode"
|
||||
case .password:
|
||||
return "Password mode"
|
||||
case .allowAll:
|
||||
return "Allow all processes"
|
||||
return "Full open access"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -29,8 +34,126 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
|
|||
return "Disable the local control socket."
|
||||
case .cmuxOnly:
|
||||
return "Only processes started inside cmux terminals can send commands."
|
||||
case .automation:
|
||||
return "Allow external local automation clients from this macOS user (no ancestry check)."
|
||||
case .password:
|
||||
return "Require socket authentication with a password stored in your keychain."
|
||||
case .allowAll:
|
||||
return "Allow any local process to connect (no ancestry check)."
|
||||
return "Allow any local process and user to connect with no auth. Unsafe."
|
||||
}
|
||||
}
|
||||
|
||||
var socketFilePermissions: UInt16 {
|
||||
switch self {
|
||||
case .allowAll:
|
||||
return 0o666
|
||||
case .off, .cmuxOnly, .automation, .password:
|
||||
return 0o600
|
||||
}
|
||||
}
|
||||
|
||||
var requiresPasswordAuth: Bool {
|
||||
self == .password
|
||||
}
|
||||
}
|
||||
|
||||
enum SocketControlPasswordStore {
|
||||
static let service = "com.cmuxterm.app.socket-control"
|
||||
static let account = "local-socket-password"
|
||||
|
||||
private static var baseQuery: [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
static func configuredPassword(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> String? {
|
||||
if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty {
|
||||
return envPassword
|
||||
}
|
||||
return try? loadPassword()
|
||||
}
|
||||
|
||||
static func hasConfiguredPassword(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
guard let configured = configuredPassword(environment: environment) else { return false }
|
||||
return !configured.isEmpty
|
||||
}
|
||||
|
||||
static func verify(
|
||||
password candidate: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool {
|
||||
guard let expected = configuredPassword(environment: environment), !expected.isEmpty else {
|
||||
return false
|
||||
}
|
||||
return expected == candidate
|
||||
}
|
||||
|
||||
static func loadPassword() throws -> String? {
|
||||
var query = baseQuery
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
}
|
||||
guard let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func savePassword(_ password: String) throws {
|
||||
let normalized = password.trimmingCharacters(in: .newlines)
|
||||
if normalized.isEmpty {
|
||||
try clearPassword()
|
||||
return
|
||||
}
|
||||
|
||||
let data = Data(normalized.utf8)
|
||||
var lookup = baseQuery
|
||||
lookup[kSecReturnData as String] = true
|
||||
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var existing: CFTypeRef?
|
||||
let lookupStatus = SecItemCopyMatching(lookup as CFDictionary, &existing)
|
||||
switch lookupStatus {
|
||||
case errSecSuccess:
|
||||
let attrsToUpdate: [String: Any] = [
|
||||
kSecValueData as String: data
|
||||
]
|
||||
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus))
|
||||
}
|
||||
case errSecItemNotFound:
|
||||
var add = baseQuery
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(addStatus))
|
||||
}
|
||||
default:
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(lookupStatus))
|
||||
}
|
||||
}
|
||||
|
||||
static func clearPassword() throws {
|
||||
let status = SecItemDelete(baseQuery as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,36 +161,131 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
|
|||
struct SocketControlSettings {
|
||||
static let appStorageKey = "socketControlMode"
|
||||
static let legacyEnabledKey = "socketControlEnabled"
|
||||
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
|
||||
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
|
||||
|
||||
/// Map old persisted rawValues to the new enum.
|
||||
static func migrateMode(_ raw: String) -> SocketControlMode {
|
||||
switch raw {
|
||||
case "off": return .off
|
||||
case "cmuxOnly": return .cmuxOnly
|
||||
case "allowAll": return .allowAll
|
||||
// Legacy values:
|
||||
case "notifications", "full": return .cmuxOnly
|
||||
default: return defaultMode
|
||||
private static func normalizeMode(_ raw: String) -> String {
|
||||
raw
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "_", with: "")
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
}
|
||||
|
||||
private static func parseMode(_ raw: String) -> SocketControlMode? {
|
||||
switch normalizeMode(raw) {
|
||||
case "off":
|
||||
return .off
|
||||
case "cmuxonly":
|
||||
return .cmuxOnly
|
||||
case "automation":
|
||||
return .automation
|
||||
case "password":
|
||||
return .password
|
||||
case "allowall", "openaccess", "fullopenaccess":
|
||||
return .allowAll
|
||||
// Legacy values from the old socket mode model.
|
||||
case "notifications":
|
||||
return .automation
|
||||
case "full":
|
||||
return .allowAll
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Map persisted values to the current enum values.
|
||||
static func migrateMode(_ raw: String) -> SocketControlMode {
|
||||
parseMode(raw) ?? defaultMode
|
||||
}
|
||||
|
||||
static var defaultMode: SocketControlMode {
|
||||
return .cmuxOnly
|
||||
}
|
||||
|
||||
static func socketPath() -> String {
|
||||
if let override = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"], !override.isEmpty {
|
||||
return override
|
||||
}
|
||||
private static var isDebugBuild: Bool {
|
||||
#if DEBUG
|
||||
return "/tmp/cmux-debug.sock"
|
||||
true
|
||||
#else
|
||||
return "/tmp/cmux.sock"
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static func envOverrideEnabled() -> Bool? {
|
||||
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
|
||||
static func socketPath(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
|
||||
) -> String {
|
||||
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
|
||||
|
||||
guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if shouldHonorSocketPathOverride(
|
||||
environment: environment,
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
isDebugBuild: isDebugBuild
|
||||
) {
|
||||
return override
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String {
|
||||
if bundleIdentifier == "com.cmuxterm.app.nightly" {
|
||||
return "/tmp/cmux-nightly.sock"
|
||||
}
|
||||
if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild {
|
||||
return "/tmp/cmux-debug.sock"
|
||||
}
|
||||
if isStagingBundleIdentifier(bundleIdentifier) {
|
||||
return "/tmp/cmux-staging.sock"
|
||||
}
|
||||
return "/tmp/cmux.sock"
|
||||
}
|
||||
|
||||
static func shouldHonorSocketPathOverride(
|
||||
environment: [String: String],
|
||||
bundleIdentifier: String?,
|
||||
isDebugBuild: Bool
|
||||
) -> Bool {
|
||||
if isTruthy(environment[allowSocketPathOverrideKey]) {
|
||||
return true
|
||||
}
|
||||
if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) {
|
||||
return true
|
||||
}
|
||||
return isDebugBuild
|
||||
}
|
||||
|
||||
static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
|
||||
guard let bundleIdentifier else { return false }
|
||||
return bundleIdentifier == "com.cmuxterm.app.debug"
|
||||
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
|
||||
}
|
||||
|
||||
static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
|
||||
guard let bundleIdentifier else { return false }
|
||||
return bundleIdentifier == "com.cmuxterm.app.staging"
|
||||
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.")
|
||||
}
|
||||
|
||||
static func isTruthy(_ raw: String?) -> Bool {
|
||||
guard let raw else { return false }
|
||||
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func envOverrideEnabled(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> Bool? {
|
||||
guard let raw = environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -81,33 +299,30 @@ struct SocketControlSettings {
|
|||
}
|
||||
}
|
||||
|
||||
static func envOverrideMode() -> SocketControlMode? {
|
||||
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
|
||||
static func envOverrideMode(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> SocketControlMode? {
|
||||
guard let raw = environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
switch cleaned {
|
||||
case "off": return .off
|
||||
case "cmuxonly", "cmux_only", "cmux-only": return .cmuxOnly
|
||||
case "allowall", "allow_all", "allow-all": return .allowAll
|
||||
// Legacy env var values — map to allowAll so existing test scripts keep working
|
||||
case "notifications", "full": return .allowAll
|
||||
default: return SocketControlMode(rawValue: cleaned)
|
||||
}
|
||||
return parseMode(raw)
|
||||
}
|
||||
|
||||
static func effectiveMode(userMode: SocketControlMode) -> SocketControlMode {
|
||||
if let overrideEnabled = envOverrideEnabled() {
|
||||
static func effectiveMode(
|
||||
userMode: SocketControlMode,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> SocketControlMode {
|
||||
if let overrideEnabled = envOverrideEnabled(environment: environment) {
|
||||
if !overrideEnabled {
|
||||
return .off
|
||||
}
|
||||
if let overrideMode = envOverrideMode() {
|
||||
if let overrideMode = envOverrideMode(environment: environment) {
|
||||
return overrideMode
|
||||
}
|
||||
return userMode == .off ? .cmuxOnly : userMode
|
||||
}
|
||||
|
||||
if let overrideMode = envOverrideMode() {
|
||||
if let overrideMode = envOverrideMode(environment: environment) {
|
||||
return overrideMode
|
||||
}
|
||||
|
||||
|
|
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ struct TitlebarControlsView: View {
|
|||
.foregroundColor(.white)
|
||||
.frame(width: config.badgeSize, height: config.badgeSize)
|
||||
.background(
|
||||
Circle().fill(Color.accentColor)
|
||||
Circle().fill(cmuxAccentColor())
|
||||
)
|
||||
.offset(x: config.badgeOffset.width, y: config.badgeOffset.height)
|
||||
}
|
||||
|
|
@ -905,11 +905,11 @@ private struct NotificationPopoverRow: View {
|
|||
Button(action: onOpen) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(notification.isRead ? Color.clear : Color.accentColor)
|
||||
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
||||
.stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
||||
)
|
||||
.padding(.top, 6)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,246 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import SwiftUI
|
||||
|
||||
private func windowDragHandleFormatPoint(_ point: NSPoint) -> String {
|
||||
String(format: "(%.1f,%.1f)", point.x, point.y)
|
||||
}
|
||||
|
||||
/// Runs the same action macOS titlebars use for double-click:
|
||||
/// zoom by default, or minimize when the user preference is set.
|
||||
@discardableResult
|
||||
func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
|
||||
guard let window else { return false }
|
||||
|
||||
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:]
|
||||
if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased() {
|
||||
switch action {
|
||||
case "minimize":
|
||||
window.miniaturize(nil)
|
||||
return true
|
||||
case "none":
|
||||
return false
|
||||
case "maximize", "zoom":
|
||||
window.zoom(nil)
|
||||
return true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
|
||||
miniaturizeOnDoubleClick {
|
||||
window.miniaturize(nil)
|
||||
return true
|
||||
}
|
||||
|
||||
window.zoom(nil)
|
||||
return true
|
||||
}
|
||||
|
||||
private var windowDragSuppressionDepthKey: UInt8 = 0
|
||||
|
||||
func beginWindowDragSuppression(window: NSWindow?) -> Int? {
|
||||
guard let window else { return nil }
|
||||
let current = windowDragSuppressionDepth(window: window)
|
||||
let next = current + 1
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
&windowDragSuppressionDepthKey,
|
||||
NSNumber(value: next),
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
return next
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func endWindowDragSuppression(window: NSWindow?) -> Int {
|
||||
guard let window else { return 0 }
|
||||
let current = windowDragSuppressionDepth(window: window)
|
||||
let next = max(0, current - 1)
|
||||
if next == 0 {
|
||||
objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
} else {
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
&windowDragSuppressionDepthKey,
|
||||
NSNumber(value: next),
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func windowDragSuppressionDepth(window: NSWindow?) -> Int {
|
||||
guard let window,
|
||||
let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else {
|
||||
return 0
|
||||
}
|
||||
return value.intValue
|
||||
}
|
||||
|
||||
func isWindowDragSuppressed(window: NSWindow?) -> Bool {
|
||||
windowDragSuppressionDepth(window: window) > 0
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func clearWindowDragSuppression(window: NSWindow?) -> Int {
|
||||
guard let window else { return 0 }
|
||||
var depth = windowDragSuppressionDepth(window: window)
|
||||
while depth > 0 {
|
||||
depth = endWindowDragSuppression(window: window)
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
/// Temporarily enables window movability for explicit drag-handle drags, then
|
||||
/// restores the previous movability state after `body` finishes.
|
||||
@discardableResult
|
||||
func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? {
|
||||
guard let window else {
|
||||
body()
|
||||
return nil
|
||||
}
|
||||
|
||||
let previousMovableState = window.isMovable
|
||||
if !previousMovableState {
|
||||
window.isMovable = true
|
||||
}
|
||||
defer {
|
||||
if window.isMovable != previousMovableState {
|
||||
window.isMovable = previousMovableState
|
||||
}
|
||||
}
|
||||
|
||||
body()
|
||||
return previousMovableState
|
||||
}
|
||||
|
||||
private enum WindowDragHandleHitTestState {
|
||||
static var isResolvingTopHit = false
|
||||
}
|
||||
|
||||
/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty
|
||||
/// titlebar space. Treat those as pass-through so explicit sibling checks decide.
|
||||
func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool {
|
||||
let className = String(describing: type(of: view))
|
||||
if className.contains("HostContainerView")
|
||||
|| className.contains("AppKitWindowHostingView")
|
||||
|| className.contains("NSHostingView") {
|
||||
return true
|
||||
}
|
||||
if let window = view.window, view === window.contentView {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns whether the titlebar drag handle should capture a hit at `point`.
|
||||
/// We only claim the hit when no sibling view already handles it, so interactive
|
||||
/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures.
|
||||
func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool {
|
||||
if isWindowDragSuppressed(window: dragHandleView.window) {
|
||||
// Recover from stale suppression if a prior interaction missed cleanup.
|
||||
// We only keep suppression active while the left mouse button is down.
|
||||
if (NSEvent.pressedMouseButtons & 0x1) == 0 {
|
||||
let clearedDepth = clearWindowDragSuppression(window: dragHandleView.window)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))"
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
let depth = windowDragSuppressionDepth(window: dragHandleView.window)
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
guard dragHandleView.bounds.contains(point) else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
guard let superview = dragHandleView.superview else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
if let window = dragHandleView.window,
|
||||
let contentView = window.contentView,
|
||||
!WindowDragHandleHitTestState.isResolvingTopHit {
|
||||
let pointInWindow = dragHandleView.convert(point, to: nil)
|
||||
let pointInContent = contentView.convert(pointInWindow, from: nil)
|
||||
|
||||
WindowDragHandleHitTestState.isResolvingTopHit = true
|
||||
let topHit = contentView.hitTest(pointInContent)
|
||||
WindowDragHandleHitTestState.isResolvingTopHit = false
|
||||
|
||||
if let topHit {
|
||||
let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView)
|
||||
let topHitBelongsToTitlebarOverlay = topHit === superview || topHit.isDescendant(of: superview)
|
||||
let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) inTitlebarOverlay=\(topHitBelongsToTitlebarOverlay) passiveHost=\(isPassiveHostHit)"
|
||||
)
|
||||
#endif
|
||||
if ownsTopHit {
|
||||
return true
|
||||
}
|
||||
// Underlay content can transiently overlap titlebar space (notably browser
|
||||
// chrome/webview layers). Only let top-hits block capture when they belong
|
||||
// to this titlebar overlay stack.
|
||||
if topHitBelongsToTitlebarOverlay && !isPassiveHostHit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let siblingCount = superview.subviews.count
|
||||
#endif
|
||||
|
||||
for sibling in superview.subviews.reversed() {
|
||||
guard sibling !== dragHandleView else { continue }
|
||||
guard !sibling.isHidden, sibling.alphaValue > 0 else { continue }
|
||||
|
||||
let pointInSibling = dragHandleView.convert(point, to: sibling)
|
||||
if let hitView = sibling.hitTest(pointInSibling) {
|
||||
let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView)
|
||||
if passiveHostHit {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true"
|
||||
)
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
/// A transparent view that enables dragging the window when clicking in empty titlebar space.
|
||||
/// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content
|
||||
/// (e.g. sidebar tab reordering) don't move the whole window.
|
||||
|
|
@ -14,8 +254,55 @@ struct WindowDragHandleView: NSViewRepresentable {
|
|||
}
|
||||
|
||||
private final class DraggableView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { true }
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { self }
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)"
|
||||
)
|
||||
#endif
|
||||
return shouldCapture ? self : nil
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let depth = windowDragSuppressionDepth(window: window)
|
||||
dlog(
|
||||
"titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)"
|
||||
)
|
||||
#endif
|
||||
|
||||
if event.clickCount >= 2 {
|
||||
let handled = performStandardTitlebarDoubleClick(window: window)
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)")
|
||||
#endif
|
||||
if handled {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard !isWindowDragSuppressed(window: window) else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
if let window {
|
||||
let previousMovableState = withTemporaryWindowMovableEnabled(window: window) {
|
||||
window.performDrag(with: event)
|
||||
}
|
||||
#if DEBUG
|
||||
let restored = previousMovableState.map { String($0) } ?? "nil"
|
||||
dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)")
|
||||
#endif
|
||||
} else {
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,27 @@ struct WorkspaceContentView: View {
|
|||
let isWorkspaceVisible: Bool
|
||||
let isWorkspaceInputActive: Bool
|
||||
let workspacePortalPriority: Int
|
||||
@State private var config = GhosttyConfig.load()
|
||||
let onThemeRefreshRequest: ((
|
||||
_ reason: String,
|
||||
_ backgroundEventId: UInt64?,
|
||||
_ backgroundSource: String?,
|
||||
_ notificationPayloadHex: String?
|
||||
) -> Void)?
|
||||
@State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit")
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
|
||||
static func panelVisibleInUI(
|
||||
isWorkspaceVisible: Bool,
|
||||
isSelectedInPane: Bool,
|
||||
isFocused: Bool
|
||||
) -> Bool {
|
||||
guard isWorkspaceVisible else { return false }
|
||||
// During pane/tab reparenting, Bonsplit can transiently report selected=false
|
||||
// for the currently focused panel. Keep focused content visible to avoid blank frames.
|
||||
return isSelectedInPane || isFocused
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let appearance = PanelAppearance.fromConfig(config)
|
||||
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
|
||||
|
|
@ -41,8 +58,15 @@ struct WorkspaceContentView: View {
|
|||
if let panel = workspace.panel(for: tab.id) {
|
||||
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
|
||||
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
|
||||
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
|
||||
let hasUnreadNotification = notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id)
|
||||
let isVisibleInUI = Self.panelVisibleInUI(
|
||||
isWorkspaceVisible: isWorkspaceVisible,
|
||||
isSelectedInPane: isSelectedInPane,
|
||||
isFocused: isFocused
|
||||
)
|
||||
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
|
||||
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
|
||||
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
|
||||
)
|
||||
PanelContentView(
|
||||
panel: panel,
|
||||
isFocused: isFocused,
|
||||
|
|
@ -58,7 +82,7 @@ struct WorkspaceContentView: View {
|
|||
// indicator and where keyboard input/flash-focus actually lands.
|
||||
guard isWorkspaceInputActive else { return }
|
||||
guard workspace.panels[panel.id] != nil else { return }
|
||||
workspace.focusPanel(panel.id)
|
||||
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
|
||||
},
|
||||
onRequestPanelFocus: {
|
||||
guard isWorkspaceInputActive else { return }
|
||||
|
|
@ -84,7 +108,7 @@ struct WorkspaceContentView: View {
|
|||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
syncBonsplitNotificationBadges()
|
||||
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor)
|
||||
refreshGhosttyAppearanceConfig(reason: "onAppear")
|
||||
}
|
||||
.onChange(of: notificationStore.notifications) { _, _ in
|
||||
syncBonsplitNotificationBadges()
|
||||
|
|
@ -93,18 +117,28 @@ struct WorkspaceContentView: View {
|
|||
syncBonsplitNotificationBadges()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
|
||||
refreshGhosttyAppearanceConfig()
|
||||
refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload")
|
||||
}
|
||||
.onChange(of: colorScheme) { _, _ in
|
||||
.onChange(of: colorScheme) { oldValue, newValue in
|
||||
// Keep split overlay color/opacity in sync with light/dark theme transitions.
|
||||
refreshGhosttyAppearanceConfig()
|
||||
refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in
|
||||
if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor {
|
||||
workspace.applyGhosttyChrome(backgroundColor: backgroundColor)
|
||||
} else {
|
||||
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor)
|
||||
}
|
||||
let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
|
||||
let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value
|
||||
let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil"
|
||||
logTheme(
|
||||
"theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
|
||||
)
|
||||
// Payload ordering can lag across rapid config/theme updates.
|
||||
// Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned
|
||||
// with Ghostty's current runtime theme.
|
||||
refreshGhosttyAppearanceConfig(
|
||||
reason: "ghosttyDefaultBackgroundDidChange",
|
||||
backgroundEventId: eventId,
|
||||
backgroundSource: source,
|
||||
notificationPayloadHex: payloadHex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,10 +172,95 @@ struct WorkspaceContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func refreshGhosttyAppearanceConfig() {
|
||||
let next = GhosttyConfig.load()
|
||||
config = next
|
||||
workspace.applyGhosttyChrome(from: next)
|
||||
static func resolveGhosttyAppearanceConfig(
|
||||
reason: String = "unspecified",
|
||||
backgroundOverride: NSColor? = nil,
|
||||
loadConfig: () -> GhosttyConfig = GhosttyConfig.load,
|
||||
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }
|
||||
) -> GhosttyConfig {
|
||||
var next = loadConfig()
|
||||
let loadedBackgroundHex = next.backgroundColor.hexString()
|
||||
let defaultBackgroundHex: String
|
||||
let resolvedBackground: NSColor
|
||||
|
||||
if let backgroundOverride {
|
||||
resolvedBackground = backgroundOverride
|
||||
defaultBackgroundHex = "skipped"
|
||||
} else {
|
||||
let fallback = defaultBackground()
|
||||
resolvedBackground = fallback
|
||||
defaultBackgroundHex = fallback.hexString()
|
||||
}
|
||||
|
||||
next.backgroundColor = resolvedBackground
|
||||
if GhosttyApp.shared.backgroundLogEnabled {
|
||||
GhosttyApp.shared.logBackground(
|
||||
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")"
|
||||
)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private func refreshGhosttyAppearanceConfig(
|
||||
reason: String,
|
||||
backgroundOverride: NSColor? = nil,
|
||||
backgroundEventId: UInt64? = nil,
|
||||
backgroundSource: String? = nil,
|
||||
notificationPayloadHex: String? = nil
|
||||
) {
|
||||
let previousBackgroundHex = config.backgroundColor.hexString()
|
||||
let next = Self.resolveGhosttyAppearanceConfig(
|
||||
reason: reason,
|
||||
backgroundOverride: backgroundOverride
|
||||
)
|
||||
let eventLabel = backgroundEventId.map(String.init) ?? "nil"
|
||||
let sourceLabel = backgroundSource ?? "nil"
|
||||
let payloadLabel = notificationPayloadHex ?? "nil"
|
||||
let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString()
|
||||
let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear"
|
||||
logTheme(
|
||||
"theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")"
|
||||
)
|
||||
withTransaction(Transaction(animation: nil)) {
|
||||
config = next
|
||||
if shouldRequestTitlebarRefresh {
|
||||
onThemeRefreshRequest?(
|
||||
reason,
|
||||
backgroundEventId,
|
||||
backgroundSource,
|
||||
notificationPayloadHex
|
||||
)
|
||||
}
|
||||
}
|
||||
if !shouldRequestTitlebarRefresh {
|
||||
logTheme(
|
||||
"theme refresh titlebar-skip workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString())"
|
||||
)
|
||||
}
|
||||
logTheme(
|
||||
"theme refresh config-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) configBg=\(config.backgroundColor.hexString())"
|
||||
)
|
||||
let chromeReason =
|
||||
"refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)"
|
||||
workspace.applyGhosttyChrome(from: next, reason: chromeReason)
|
||||
if let terminalPanel = workspace.focusedTerminalPanel {
|
||||
terminalPanel.applyWindowBackgroundIfActive()
|
||||
logTheme(
|
||||
"theme refresh terminal-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) panel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
|
||||
)
|
||||
} else {
|
||||
logTheme(
|
||||
"theme refresh terminal-skipped workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) focusedPanel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
|
||||
)
|
||||
}
|
||||
logTheme(
|
||||
"theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")"
|
||||
)
|
||||
}
|
||||
|
||||
private func logTheme(_ message: String) {
|
||||
guard GhosttyApp.shared.backgroundLogEnabled else { return }
|
||||
GhosttyApp.shared.logBackground(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +293,8 @@ extension WorkspaceContentView {
|
|||
struct EmptyPanelView: View {
|
||||
@ObservedObject var workspace: Workspace
|
||||
let paneId: PaneID
|
||||
@AppStorage(KeyboardShortcutSettings.Action.newSurface.defaultsKey) private var newSurfaceShortcutData = Data()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.openBrowser.defaultsKey) private var openBrowserShortcutData = Data()
|
||||
|
||||
private struct ShortcutHint: View {
|
||||
let text: String
|
||||
|
|
@ -208,6 +329,49 @@ struct EmptyPanelView: View {
|
|||
_ = workspace.newBrowserSurface(inPane: paneId)
|
||||
}
|
||||
|
||||
private var newSurfaceShortcut: StoredShortcut {
|
||||
decodeShortcut(from: newSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.newSurface.defaultShortcut)
|
||||
}
|
||||
|
||||
private var openBrowserShortcut: StoredShortcut {
|
||||
decodeShortcut(from: openBrowserShortcutData, fallback: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut)
|
||||
}
|
||||
|
||||
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
||||
guard !data.isEmpty,
|
||||
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
||||
return fallback
|
||||
}
|
||||
return shortcut
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func emptyPaneActionButton(
|
||||
title: String,
|
||||
systemImage: String,
|
||||
shortcut: StoredShortcut,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
if let key = shortcut.keyEquivalent {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Label(title, systemImage: systemImage)
|
||||
ShortcutHint(text: shortcut.displayString)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(key, modifiers: shortcut.eventModifiers)
|
||||
} else {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Label(title, systemImage: systemImage)
|
||||
ShortcutHint(text: shortcut.displayString)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "terminal.fill")
|
||||
|
|
@ -219,27 +383,19 @@ struct EmptyPanelView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
createTerminal()
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Label("Terminal", systemImage: "terminal.fill")
|
||||
ShortcutHint(text: "⌘T")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut("t", modifiers: [.command])
|
||||
emptyPaneActionButton(
|
||||
title: "Terminal",
|
||||
systemImage: "terminal.fill",
|
||||
shortcut: newSurfaceShortcut,
|
||||
action: createTerminal
|
||||
)
|
||||
|
||||
Button {
|
||||
createBrowser()
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Label("Browser", systemImage: "globe")
|
||||
ShortcutHint(text: "⌘⇧L")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut("l", modifiers: [.command, .shift])
|
||||
emptyPaneActionButton(
|
||||
title: "Browser",
|
||||
systemImage: "globe",
|
||||
shortcut: openBrowserShortcut,
|
||||
action: createBrowser
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
450
cmuxTests/AppDelegateShortcutRoutingTests.swift
Normal file
450
cmuxTests/AppDelegateShortcutRoutingTests.swift
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AppDelegateShortcutRoutingTests: XCTestCase {
|
||||
func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
|
||||
XCTAssertTrue(appDelegate.focusMainWindow(windowId: firstWindowId))
|
||||
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: secondWindow.windowNumber,
|
||||
context: nil,
|
||||
characters: "n",
|
||||
charactersIgnoringModifiers: "n",
|
||||
isARepeat: false,
|
||||
keyCode: 45
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+N event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not add workspace to stale active window")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should add workspace to the event's window")
|
||||
}
|
||||
|
||||
func testAddWorkspaceInPreferredMainWindowIgnoresStaleTabManagerPointer() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
// Force a stale app-level pointer to a different manager.
|
||||
appDelegate.tabManager = firstManager
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager)
|
||||
|
||||
_ = appDelegate.addWorkspaceInPreferredMainWindow()
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Stale pointer must not receive menu-driven workspace creation")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Workspace creation should target key/main window context")
|
||||
}
|
||||
|
||||
func testCmdNResolvesEventWindowWhenObjectKeyLookupIsMismatched() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId))
|
||||
#else
|
||||
XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
// Ensure stale active-manager pointer does not mask routing errors.
|
||||
appDelegate.tabManager = firstManager
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: secondWindow.windowNumber,
|
||||
context: nil,
|
||||
characters: "n",
|
||||
charactersIgnoringModifiers: "n",
|
||||
isARepeat: false,
|
||||
keyCode: 45
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+N event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not route to another window when object-key lookup misses")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should still route by event window metadata when object-key lookup misses")
|
||||
}
|
||||
|
||||
func testAddWorkspaceInPreferredMainWindowUsesKeyWindowWhenObjectKeyLookupIsMismatched() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId))
|
||||
#else
|
||||
XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
// Stale pointer should not receive the new workspace.
|
||||
appDelegate.tabManager = firstManager
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
|
||||
_ = appDelegate.addWorkspaceInPreferredMainWindow()
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Menu-driven add workspace should not route to stale window")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses")
|
||||
}
|
||||
|
||||
func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
_ = firstManager.addTab(select: true)
|
||||
_ = secondManager.addTab(select: true)
|
||||
|
||||
guard let firstSelectedBefore = firstManager.selectedTabId,
|
||||
let secondSelectedBefore = secondManager.selectedTabId else {
|
||||
XCTFail("Expected selected tabs in both windows")
|
||||
return
|
||||
}
|
||||
guard let secondFirstTabId = secondManager.tabs.first?.id else {
|
||||
XCTFail("Expected at least one tab in second window")
|
||||
return
|
||||
}
|
||||
|
||||
appDelegate.tabManager = firstManager
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager)
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "1",
|
||||
modifiers: [.command],
|
||||
keyCode: 18, // kVK_ANSI_1
|
||||
windowNumber: secondWindow.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+1 event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Cmd+1 must not select a tab in stale active window")
|
||||
XCTAssertNotEqual(secondManager.selectedTabId, secondSelectedBefore, "Cmd+1 should change tab selection in event window")
|
||||
XCTAssertEqual(secondManager.selectedTabId, secondFirstTabId, "Cmd+1 should select first tab in the event window")
|
||||
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
|
||||
}
|
||||
|
||||
func testCmdTRoutesToEventWindowWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId),
|
||||
let firstWorkspace = firstManager.selectedWorkspace,
|
||||
let secondWorkspace = secondManager.selectedWorkspace else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
let firstSurfaceCount = firstWorkspace.panels.count
|
||||
let secondSurfaceCount = secondWorkspace.panels.count
|
||||
|
||||
appDelegate.tabManager = firstManager
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager)
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "t",
|
||||
modifiers: [.command],
|
||||
keyCode: 17, // kVK_ANSI_T
|
||||
windowNumber: secondWindow.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+T event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+T must not create a surface in stale active window")
|
||||
XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+T should create a surface in the event window")
|
||||
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
|
||||
}
|
||||
|
||||
func testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
_ = firstManager.addTab(select: true)
|
||||
_ = secondManager.addTab(select: true)
|
||||
guard let firstSelectedBefore = firstManager.selectedTabId,
|
||||
let secondSelectedBefore = secondManager.selectedTabId else {
|
||||
XCTFail("Expected selected tabs in both windows")
|
||||
return
|
||||
}
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
// Force stale app-level manager to first window while keyboard event
|
||||
// references no known window.
|
||||
appDelegate.tabManager = firstManager
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "1",
|
||||
modifiers: [.command],
|
||||
keyCode: 18,
|
||||
windowNumber: Int.max
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+1 event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Unresolved event window must not route Cmd+1 into stale manager")
|
||||
XCTAssertEqual(secondManager.selectedTabId, secondSelectedBefore, "Unresolved event window must not route Cmd+1 into key/main fallback manager")
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
|
||||
}
|
||||
|
||||
func testCmdNDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let secondWindow = window(withId: secondWindowId) else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
secondWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
let firstCount = firstManager.tabs.count
|
||||
let secondCount = secondManager.tabs.count
|
||||
appDelegate.tabManager = firstManager
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "n",
|
||||
modifiers: [.command],
|
||||
keyCode: 45,
|
||||
windowNumber: Int.max
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+N event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
XCTAssertEqual(firstManager.tabs.count, firstCount, "Unresolved event window must not create workspace in stale manager")
|
||||
XCTAssertEqual(secondManager.tabs.count, secondCount, "Unresolved event window must not create workspace in fallback window")
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
|
||||
}
|
||||
|
||||
private func makeKeyDownEvent(
|
||||
key: String,
|
||||
modifiers: NSEvent.ModifierFlags,
|
||||
keyCode: UInt16,
|
||||
windowNumber: Int
|
||||
) -> NSEvent? {
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: modifiers,
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: windowNumber,
|
||||
context: nil,
|
||||
characters: key,
|
||||
charactersIgnoringModifiers: key,
|
||||
isARepeat: false,
|
||||
keyCode: keyCode
|
||||
)
|
||||
}
|
||||
|
||||
private func window(withId windowId: UUID) -> NSWindow? {
|
||||
let identifier = "cmux.main.\(windowId.uuidString)"
|
||||
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
|
||||
}
|
||||
|
||||
private func closeWindow(withId windowId: UUID) {
|
||||
guard let window = window(withId: windowId) else { return }
|
||||
window.performClose(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -162,7 +162,70 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testClaudeCodeIntegrationDefaultsToDisabledWhenUnset() {
|
||||
func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() {
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .unscoped,
|
||||
incomingScope: .app
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .app,
|
||||
incomingScope: .surface
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .surface,
|
||||
incomingScope: .surface
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .surface,
|
||||
incomingScope: .app
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
|
||||
currentScope: .surface,
|
||||
incomingScope: .unscoped
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testAppearanceChangeReloadsWhenColorSchemeChanges() {
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
||||
previousColorScheme: .dark,
|
||||
currentColorScheme: .light
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
||||
previousColorScheme: nil,
|
||||
currentColorScheme: .dark
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() {
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
||||
previousColorScheme: .light,
|
||||
currentColorScheme: .light
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
|
||||
previousColorScheme: .dark,
|
||||
currentColorScheme: .dark
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() {
|
||||
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated user defaults suite")
|
||||
|
|
@ -173,7 +236,7 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
}
|
||||
|
||||
defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
|
||||
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
||||
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testClaudeCodeIntegrationRespectsStoredPreference() {
|
||||
|
|
@ -208,6 +271,75 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class WorkspaceChromeThemeTests: XCTestCase {
|
||||
func testResolvedChromeColorsUsesLightGhosttyBackground() {
|
||||
guard let backgroundColor = NSColor(hex: "#FDF6E3") else {
|
||||
XCTFail("Expected valid test color")
|
||||
return
|
||||
}
|
||||
|
||||
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
|
||||
XCTAssertEqual(colors.backgroundHex, "#FDF6E3")
|
||||
XCTAssertNil(colors.borderHex)
|
||||
}
|
||||
|
||||
func testResolvedChromeColorsUsesDarkGhosttyBackground() {
|
||||
guard let backgroundColor = NSColor(hex: "#272822") else {
|
||||
XCTFail("Expected valid test color")
|
||||
return
|
||||
}
|
||||
|
||||
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
|
||||
XCTAssertEqual(colors.backgroundHex, "#272822")
|
||||
XCTAssertNil(colors.borderHex)
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceAppearanceConfigResolutionTests: XCTestCase {
|
||||
func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() {
|
||||
guard let loadedBackground = NSColor(hex: "#112233"),
|
||||
let runtimeBackground = NSColor(hex: "#FDF6E3"),
|
||||
let loadedForeground = NSColor(hex: "#ABCDEF") else {
|
||||
XCTFail("Expected valid test colors")
|
||||
return
|
||||
}
|
||||
|
||||
var loaded = GhosttyConfig()
|
||||
loaded.backgroundColor = loadedBackground
|
||||
loaded.foregroundColor = loadedForeground
|
||||
loaded.unfocusedSplitOpacity = 0.42
|
||||
|
||||
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
|
||||
loadConfig: { loaded },
|
||||
defaultBackground: { runtimeBackground }
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3")
|
||||
XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF")
|
||||
XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() {
|
||||
guard let loadedBackground = NSColor(hex: "#112233"),
|
||||
let runtimeBackground = NSColor(hex: "#FDF6E3"),
|
||||
let explicitOverride = NSColor(hex: "#272822") else {
|
||||
XCTFail("Expected valid test colors")
|
||||
return
|
||||
}
|
||||
|
||||
var loaded = GhosttyConfig()
|
||||
loaded.backgroundColor = loadedBackground
|
||||
|
||||
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
|
||||
backgroundOverride: explicitOverride,
|
||||
loadConfig: { loaded },
|
||||
defaultBackground: { runtimeBackground }
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822")
|
||||
}
|
||||
}
|
||||
|
||||
final class NotificationBurstCoalescerTests: XCTestCase {
|
||||
func testSignalsInSameBurstFlushOnce() {
|
||||
let coalescer = NotificationBurstCoalescer(delay: 0.01)
|
||||
|
|
@ -271,6 +403,133 @@ final class NotificationBurstCoalescerTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase {
|
||||
func testSignalCoalescesBurstToLatestBackground() {
|
||||
guard let dark = NSColor(hex: "#272822"),
|
||||
let light = NSColor(hex: "#FDF6E3") else {
|
||||
XCTFail("Expected valid test colors")
|
||||
return
|
||||
}
|
||||
|
||||
let expectation = expectation(description: "coalesced notification")
|
||||
expectation.expectedFulfillmentCount = 1
|
||||
var postedUserInfos: [[AnyHashable: Any]] = []
|
||||
|
||||
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
||||
delay: 0.01,
|
||||
postNotification: { userInfo in
|
||||
postedUserInfos.append(userInfo)
|
||||
expectation.fulfill()
|
||||
}
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark")
|
||||
dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light")
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
XCTAssertEqual(postedUserInfos.count, 1)
|
||||
XCTAssertEqual(
|
||||
(postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(),
|
||||
"#FDF6E3"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]),
|
||||
0.75,
|
||||
accuracy: 0.0001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
(postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value,
|
||||
2
|
||||
)
|
||||
XCTAssertEqual(
|
||||
postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String,
|
||||
"test.light"
|
||||
)
|
||||
}
|
||||
|
||||
func testSignalAcrossSeparateBurstsPostsMultipleNotifications() {
|
||||
guard let dark = NSColor(hex: "#272822"),
|
||||
let light = NSColor(hex: "#FDF6E3") else {
|
||||
XCTFail("Expected valid test colors")
|
||||
return
|
||||
}
|
||||
|
||||
let expectation = expectation(description: "two notifications")
|
||||
expectation.expectedFulfillmentCount = 2
|
||||
var postedHexes: [String] = []
|
||||
|
||||
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
|
||||
delay: 0.01,
|
||||
postNotification: { userInfo in
|
||||
let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
|
||||
postedHexes.append(hex)
|
||||
expectation.fulfill()
|
||||
}
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light")
|
||||
}
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"])
|
||||
}
|
||||
|
||||
private func postedOpacity(from value: Any?) -> Double {
|
||||
if let value = value as? Double {
|
||||
return value
|
||||
}
|
||||
if let value = value as? NSNumber {
|
||||
return value.doubleValue
|
||||
}
|
||||
XCTFail("Expected background opacity payload")
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
final class RecentlyClosedBrowserStackTests: XCTestCase {
|
||||
func testPopReturnsEntriesInLIFOOrder() {
|
||||
var stack = RecentlyClosedBrowserStack(capacity: 20)
|
||||
stack.push(makeSnapshot(index: 1))
|
||||
stack.push(makeSnapshot(index: 2))
|
||||
stack.push(makeSnapshot(index: 3))
|
||||
|
||||
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
|
||||
XCTAssertEqual(stack.pop()?.originalTabIndex, 2)
|
||||
XCTAssertEqual(stack.pop()?.originalTabIndex, 1)
|
||||
XCTAssertNil(stack.pop())
|
||||
}
|
||||
|
||||
func testPushDropsOldestEntriesWhenCapacityExceeded() {
|
||||
var stack = RecentlyClosedBrowserStack(capacity: 3)
|
||||
for index in 1...5 {
|
||||
stack.push(makeSnapshot(index: index))
|
||||
}
|
||||
|
||||
XCTAssertEqual(stack.pop()?.originalTabIndex, 5)
|
||||
XCTAssertEqual(stack.pop()?.originalTabIndex, 4)
|
||||
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
|
||||
XCTAssertNil(stack.pop())
|
||||
}
|
||||
|
||||
private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot {
|
||||
ClosedBrowserPanelRestoreSnapshot(
|
||||
workspaceId: UUID(),
|
||||
url: URL(string: "https://example.com/\(index)"),
|
||||
originalPaneId: UUID(),
|
||||
originalTabIndex: index,
|
||||
fallbackSplitOrientation: .horizontal,
|
||||
fallbackSplitInsertFirst: false,
|
||||
fallbackAnchorPaneId: UUID()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class TabManagerNotificationOrderingSourceTests: XCTestCase {
|
||||
func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws {
|
||||
let projectRoot = findProjectRoot()
|
||||
|
|
@ -316,3 +575,168 @@ final class TabManagerNotificationOrderingSourceTests: XCTestCase {
|
|||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
final class SocketControlSettingsTests: XCTestCase {
|
||||
func testMigrateModeSupportsExpandedSocketModes() {
|
||||
XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off)
|
||||
XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly)
|
||||
XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation)
|
||||
XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password)
|
||||
XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll)
|
||||
|
||||
// Legacy aliases
|
||||
XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation)
|
||||
XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll)
|
||||
}
|
||||
|
||||
func testSocketModePermissions() {
|
||||
XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600)
|
||||
XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600)
|
||||
XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600)
|
||||
XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600)
|
||||
XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666)
|
||||
}
|
||||
|
||||
func testInvalidEnvSocketModeDoesNotOverrideUserMode() {
|
||||
XCTAssertNil(
|
||||
SocketControlSettings.envOverrideMode(
|
||||
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.effectiveMode(
|
||||
userMode: .password,
|
||||
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
|
||||
),
|
||||
.password
|
||||
)
|
||||
}
|
||||
|
||||
func testStableReleaseIgnoresAmbientSocketOverrideByDefault() {
|
||||
let path = SocketControlSettings.socketPath(
|
||||
environment: [
|
||||
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
||||
],
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
isDebugBuild: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, "/tmp/cmux.sock")
|
||||
}
|
||||
|
||||
func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() {
|
||||
let path = SocketControlSettings.socketPath(
|
||||
environment: [
|
||||
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
|
||||
],
|
||||
bundleIdentifier: "com.cmuxterm.app.nightly",
|
||||
isDebugBuild: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, "/tmp/cmux-nightly.sock")
|
||||
}
|
||||
|
||||
func testDebugBundleHonorsSocketOverrideWithoutOptInFlag() {
|
||||
let path = SocketControlSettings.socketPath(
|
||||
environment: [
|
||||
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-my-tag.sock",
|
||||
],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug.my-tag",
|
||||
isDebugBuild: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock")
|
||||
}
|
||||
|
||||
func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() {
|
||||
let path = SocketControlSettings.socketPath(
|
||||
environment: [
|
||||
"CMUX_SOCKET_PATH": "/tmp/cmux-staging-my-tag.sock",
|
||||
],
|
||||
bundleIdentifier: "com.cmuxterm.app.staging.my-tag",
|
||||
isDebugBuild: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, "/tmp/cmux-staging-my-tag.sock")
|
||||
}
|
||||
|
||||
func testStableReleaseCanOptInToSocketOverride() {
|
||||
let path = SocketControlSettings.socketPath(
|
||||
environment: [
|
||||
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-forced.sock",
|
||||
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
|
||||
],
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
isDebugBuild: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
|
||||
}
|
||||
|
||||
func testDefaultSocketPathByChannel() {
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false),
|
||||
"/tmp/cmux.sock"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false),
|
||||
"/tmp/cmux-nightly.sock"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false),
|
||||
"/tmp/cmux-debug.sock"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false),
|
||||
"/tmp/cmux-staging.sock"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class PostHogAnalyticsPropertiesTests: XCTestCase {
|
||||
func testDailyActivePropertiesIncludeVersionAndBuild() {
|
||||
let properties = PostHogAnalytics.dailyActiveProperties(
|
||||
dayUTC: "2026-02-21",
|
||||
reason: "didBecomeActive",
|
||||
infoDictionary: [
|
||||
"CFBundleShortVersionString": "0.31.0",
|
||||
"CFBundleVersion": "230",
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(properties["day_utc"] as? String, "2026-02-21")
|
||||
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
|
||||
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
||||
XCTAssertEqual(properties["app_build"] as? String, "230")
|
||||
}
|
||||
|
||||
func testSuperPropertiesIncludePlatformVersionAndBuild() {
|
||||
let properties = PostHogAnalytics.superProperties(
|
||||
infoDictionary: [
|
||||
"CFBundleShortVersionString": "0.31.0",
|
||||
"CFBundleVersion": "230",
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(properties["platform"] as? String, "cmuxterm")
|
||||
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
|
||||
XCTAssertEqual(properties["app_build"] as? String, "230")
|
||||
}
|
||||
|
||||
func testPropertiesOmitVersionFieldsWhenUnavailable() {
|
||||
let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:])
|
||||
XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm")
|
||||
XCTAssertNil(superProperties["app_version"])
|
||||
XCTAssertNil(superProperties["app_build"])
|
||||
|
||||
let dailyProperties = PostHogAnalytics.dailyActiveProperties(
|
||||
dayUTC: "2026-02-21",
|
||||
reason: "activeTimer",
|
||||
infoDictionary: [:]
|
||||
)
|
||||
XCTAssertEqual(dailyProperties["day_utc"] as? String, "2026-02-21")
|
||||
XCTAssertEqual(dailyProperties["reason"] as? String, "activeTimer")
|
||||
XCTAssertNil(dailyProperties["app_version"])
|
||||
XCTAssertNil(dailyProperties["app_build"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
645
cmuxTests/SessionPersistenceTests.swift
Normal file
645
cmuxTests/SessionPersistenceTests.swift
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class SessionPersistenceTests: XCTestCase {
|
||||
func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
|
||||
let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
|
||||
|
||||
XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
|
||||
|
||||
let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
|
||||
XCTAssertNotNil(loaded)
|
||||
XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion)
|
||||
XCTAssertEqual(loaded?.windows.count, 1)
|
||||
XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs)
|
||||
let frame = try XCTUnwrap(loaded?.windows.first?.frame)
|
||||
XCTAssertEqual(frame.x, 10, accuracy: 0.001)
|
||||
XCTAssertEqual(frame.y, 20, accuracy: 0.001)
|
||||
XCTAssertEqual(frame.width, 900, accuracy: 0.001)
|
||||
XCTAssertEqual(frame.height, 700, accuracy: 0.001)
|
||||
XCTAssertEqual(loaded?.windows.first?.display?.displayID, 42)
|
||||
let visibleFrame = try XCTUnwrap(loaded?.windows.first?.display?.visibleFrame)
|
||||
XCTAssertEqual(visibleFrame.y, 25, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
|
||||
var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
|
||||
snapshot.windows[0].tabManager.workspaces[0].customColor = "#C0392B"
|
||||
|
||||
XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
|
||||
|
||||
let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
|
||||
XCTAssertEqual(
|
||||
loaded?.windows.first?.tabManager.workspaces.first?.customColor,
|
||||
"#C0392B"
|
||||
)
|
||||
}
|
||||
|
||||
func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws {
|
||||
var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
|
||||
snapshot.windows[0].tabManager.workspaces[0].customColor = nil
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(snapshot)
|
||||
let json = try XCTUnwrap(String(data: data, encoding: .utf8))
|
||||
XCTAssertFalse(json.contains("\"customColor\""))
|
||||
|
||||
let decoded = try JSONDecoder().decode(AppSessionSnapshot.self, from: data)
|
||||
XCTAssertNil(decoded.windows.first?.tabManager.workspaces.first?.customColor)
|
||||
}
|
||||
|
||||
func testLoadRejectsSchemaVersionMismatch() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
|
||||
XCTAssertTrue(SessionPersistenceStore.save(makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1), fileURL: snapshotURL))
|
||||
|
||||
XCTAssertNil(SessionPersistenceStore.load(fileURL: snapshotURL))
|
||||
}
|
||||
|
||||
func testDefaultSnapshotPathSanitizesBundleIdentifier() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let path = SessionPersistenceStore.defaultSnapshotFileURL(
|
||||
bundleIdentifier: "com.example/unsafe id",
|
||||
appSupportDirectory: tempDir
|
||||
)
|
||||
|
||||
XCTAssertNotNil(path)
|
||||
XCTAssertTrue(path?.path.contains("com.example_unsafe_id") == true)
|
||||
}
|
||||
|
||||
func testRestorePolicySkipsWhenLaunchHasExplicitArguments() {
|
||||
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
|
||||
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "--window", "window:1"],
|
||||
environment: [:]
|
||||
)
|
||||
|
||||
XCTAssertFalse(shouldRestore)
|
||||
}
|
||||
|
||||
func testRestorePolicyAllowsFinderStyleLaunchArgumentsOnly() {
|
||||
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
|
||||
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "-psn_0_12345"],
|
||||
environment: [:]
|
||||
)
|
||||
|
||||
XCTAssertTrue(shouldRestore)
|
||||
}
|
||||
|
||||
func testRestorePolicySkipsWhenRunningUnderXCTest() {
|
||||
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
|
||||
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux"],
|
||||
environment: ["XCTestConfigurationFilePath": "/tmp/xctest.xctestconfiguration"]
|
||||
)
|
||||
|
||||
XCTAssertFalse(shouldRestore)
|
||||
}
|
||||
|
||||
func testSidebarWidthSanitizationClampsToPolicyRange() {
|
||||
XCTAssertEqual(
|
||||
SessionPersistencePolicy.sanitizedSidebarWidth(-20),
|
||||
SessionPersistencePolicy.minimumSidebarWidth,
|
||||
accuracy: 0.001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SessionPersistencePolicy.sanitizedSidebarWidth(10_000),
|
||||
SessionPersistencePolicy.maximumSidebarWidth,
|
||||
accuracy: 0.001
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SessionPersistencePolicy.sanitizedSidebarWidth(nil),
|
||||
SessionPersistencePolicy.defaultSidebarWidth,
|
||||
accuracy: 0.001
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionRectSnapshotEncodesXYWidthHeightKeys() throws {
|
||||
let snapshot = SessionRectSnapshot(x: 101.25, y: 202.5, width: 903.75, height: 704.5)
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Double])
|
||||
|
||||
XCTAssertEqual(Set(object.keys), Set(["x", "y", "width", "height"]))
|
||||
XCTAssertEqual(try XCTUnwrap(object["x"]), 101.25, accuracy: 0.001)
|
||||
XCTAssertEqual(try XCTUnwrap(object["y"]), 202.5, accuracy: 0.001)
|
||||
XCTAssertEqual(try XCTUnwrap(object["width"]), 903.75, accuracy: 0.001)
|
||||
XCTAssertEqual(try XCTUnwrap(object["height"]), 704.5, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
|
||||
let source = SessionBrowserPanelSnapshot(
|
||||
urlString: "https://example.com/current",
|
||||
shouldRenderWebView: true,
|
||||
pageZoom: 1.2,
|
||||
developerToolsVisible: true,
|
||||
backHistoryURLStrings: [
|
||||
"https://example.com/a",
|
||||
"https://example.com/b"
|
||||
],
|
||||
forwardHistoryURLStrings: [
|
||||
"https://example.com/d"
|
||||
]
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(source)
|
||||
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
|
||||
XCTAssertEqual(decoded.urlString, source.urlString)
|
||||
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
|
||||
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
|
||||
}
|
||||
|
||||
func testSessionBrowserPanelSnapshotHistoryDecodesWhenKeysAreMissing() throws {
|
||||
let json = """
|
||||
{
|
||||
"urlString": "https://example.com/current",
|
||||
"shouldRenderWebView": true,
|
||||
"pageZoom": 1.0,
|
||||
"developerToolsVisible": false
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
|
||||
XCTAssertEqual(decoded.urlString, "https://example.com/current")
|
||||
XCTAssertNil(decoded.backHistoryURLStrings)
|
||||
XCTAssertNil(decoded.forwardHistoryURLStrings)
|
||||
}
|
||||
|
||||
func testScrollbackReplayEnvironmentWritesReplayFile() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let environment = SessionScrollbackReplayStore.replayEnvironment(
|
||||
for: "line one\nline two\n",
|
||||
tempDirectory: tempDir
|
||||
)
|
||||
|
||||
let path = environment[SessionScrollbackReplayStore.environmentKey]
|
||||
XCTAssertNotNil(path)
|
||||
XCTAssertTrue(path?.hasPrefix(tempDir.path) == true)
|
||||
|
||||
guard let path else { return }
|
||||
let contents = try? String(contentsOfFile: path, encoding: .utf8)
|
||||
XCTAssertEqual(contents, "line one\nline two\n")
|
||||
}
|
||||
|
||||
func testScrollbackReplayEnvironmentSkipsWhitespaceOnlyContent() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let environment = SessionScrollbackReplayStore.replayEnvironment(
|
||||
for: " \n\t ",
|
||||
tempDirectory: tempDir
|
||||
)
|
||||
|
||||
XCTAssertTrue(environment.isEmpty)
|
||||
}
|
||||
|
||||
func testScrollbackReplayEnvironmentPreservesANSIColorSequences() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let red = "\u{001B}[31m"
|
||||
let reset = "\u{001B}[0m"
|
||||
let source = "\(red)RED\(reset)\n"
|
||||
let environment = SessionScrollbackReplayStore.replayEnvironment(
|
||||
for: source,
|
||||
tempDirectory: tempDir
|
||||
)
|
||||
|
||||
guard let path = environment[SessionScrollbackReplayStore.environmentKey] else {
|
||||
XCTFail("Expected replay file path")
|
||||
return
|
||||
}
|
||||
|
||||
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
|
||||
XCTFail("Expected replay file contents")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(contents.contains("\(red)RED\(reset)"))
|
||||
XCTAssertTrue(contents.hasPrefix(reset))
|
||||
XCTAssertTrue(contents.hasSuffix(reset))
|
||||
}
|
||||
|
||||
func testTruncatedScrollbackAvoidsLeadingPartialANSICSISequence() {
|
||||
let maxChars = SessionPersistencePolicy.maxScrollbackCharactersPerTerminal
|
||||
let source = "\u{001B}[31m"
|
||||
+ String(repeating: "X", count: maxChars - 7)
|
||||
+ "\u{001B}[0m"
|
||||
|
||||
guard let truncated = SessionPersistencePolicy.truncatedScrollback(source) else {
|
||||
XCTFail("Expected truncated scrollback")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertFalse(truncated.hasPrefix("31m"))
|
||||
XCTAssertFalse(truncated.hasPrefix("[31m"))
|
||||
XCTAssertFalse(truncated.hasPrefix("m"))
|
||||
}
|
||||
|
||||
func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() {
|
||||
XCTAssertEqual(
|
||||
TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"),
|
||||
"/tmp/cmux-screen.txt"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "),
|
||||
"/tmp/cmux-screen.txt"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() {
|
||||
XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt"))
|
||||
XCTAssertNil(TerminalController.normalizedExportedScreenPath(" "))
|
||||
XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil))
|
||||
}
|
||||
|
||||
func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() {
|
||||
let tempRoot = URL(fileURLWithPath: "/tmp")
|
||||
.appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
let tempFile = tempRoot
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
.appendingPathComponent("screen.txt", isDirectory: false)
|
||||
let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt")
|
||||
|
||||
XCTAssertTrue(
|
||||
TerminalController.shouldRemoveExportedScreenDirectory(
|
||||
fileURL: tempFile,
|
||||
temporaryDirectory: tempRoot
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
TerminalController.shouldRemoveExportedScreenDirectory(
|
||||
fileURL: outsideFile,
|
||||
temporaryDirectory: tempRoot
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testShouldRemoveExportedScreenFileOnlyWithinTemporaryRoot() {
|
||||
let tempRoot = URL(fileURLWithPath: "/tmp")
|
||||
.appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
let tempFile = tempRoot
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
.appendingPathComponent("screen.txt", isDirectory: false)
|
||||
let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt")
|
||||
|
||||
XCTAssertTrue(
|
||||
TerminalController.shouldRemoveExportedScreenFile(
|
||||
fileURL: tempFile,
|
||||
temporaryDirectory: tempRoot
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
TerminalController.shouldRemoveExportedScreenFile(
|
||||
fileURL: outsideFile,
|
||||
temporaryDirectory: tempRoot
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testWindowUnregisterSnapshotPersistencePolicy() {
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: false)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: true)
|
||||
)
|
||||
}
|
||||
|
||||
func testShouldSkipSessionSaveDuringStartupRestorePolicy() {
|
||||
XCTAssertTrue(
|
||||
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
|
||||
isApplyingStartupSessionRestore: true,
|
||||
includeScrollback: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
|
||||
isApplyingStartupSessionRestore: true,
|
||||
includeScrollback: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
|
||||
isApplyingStartupSessionRestore: false,
|
||||
includeScrollback: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testResolvedWindowFramePrefersSavedDisplayIdentity() {
|
||||
let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400)
|
||||
let savedDisplay = SessionDisplaySnapshot(
|
||||
displayID: 2,
|
||||
frame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
|
||||
// Display 1 and 2 swapped horizontal positions between snapshot and restore.
|
||||
let display1 = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
let display2 = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 2,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: savedDisplay,
|
||||
availableDisplays: [display1, display2],
|
||||
fallbackDisplay: display1
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertTrue(display2.visibleFrame.intersects(restored))
|
||||
XCTAssertFalse(display1.visibleFrame.intersects(restored))
|
||||
XCTAssertEqual(restored.width, 600, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 400, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minX, 200, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 100, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedWindowFrameKeepsIntersectingFrameWithoutDisplayMetadata() {
|
||||
let savedFrame = SessionRectSnapshot(x: 120, y: 80, width: 500, height: 350)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: nil,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertEqual(restored.minX, 120, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 80, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 500, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 350, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedStartupPrimaryWindowFrameFallsBackToPersistedGeometryWhenPrimaryMissing() {
|
||||
let fallbackFrame = SessionRectSnapshot(x: 180, y: 140, width: 900, height: 640)
|
||||
let fallbackDisplay = SessionDisplaySnapshot(
|
||||
displayID: 1,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedStartupPrimaryWindowFrame(
|
||||
primarySnapshot: nil,
|
||||
fallbackFrame: fallbackFrame,
|
||||
fallbackDisplaySnapshot: fallbackDisplay,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertEqual(restored.minX, 180, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 140, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 900, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 640, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedStartupPrimaryWindowFramePrefersPrimarySnapshotOverFallback() {
|
||||
let primarySnapshot = SessionWindowSnapshot(
|
||||
frame: SessionRectSnapshot(x: 220, y: 160, width: 980, height: 700),
|
||||
display: SessionDisplaySnapshot(
|
||||
displayID: 1,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
),
|
||||
tabManager: SessionTabManagerSnapshot(selectedWorkspaceIndex: nil, workspaces: []),
|
||||
sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 220)
|
||||
)
|
||||
let fallbackFrame = SessionRectSnapshot(x: 40, y: 30, width: 700, height: 500)
|
||||
let fallbackDisplay = SessionDisplaySnapshot(
|
||||
displayID: 1,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedStartupPrimaryWindowFrame(
|
||||
primarySnapshot: primarySnapshot,
|
||||
fallbackFrame: fallbackFrame,
|
||||
fallbackDisplaySnapshot: fallbackDisplay,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertEqual(restored.minX, 220, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 160, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 980, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 700, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() {
|
||||
let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 1,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: nil,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertTrue(display.visibleFrame.contains(restored))
|
||||
XCTAssertEqual(restored.minX, 50, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, 50, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 900, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 700, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedWindowFramePreservesExactGeometryWhenDisplayIsUnchanged() {
|
||||
let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410)
|
||||
let savedDisplay = SessionDisplaySnapshot(
|
||||
displayID: 2,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410)
|
||||
)
|
||||
let display = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 2,
|
||||
frame: CGRect(x: 0, y: 0, width: 2_560, height: 1_440),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 2_560, height: 1_410)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: savedDisplay,
|
||||
availableDisplays: [display],
|
||||
fallbackDisplay: display
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertEqual(restored.minX, 1_303, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.minY, -90, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.width, 1_280, accuracy: 0.001)
|
||||
XCTAssertEqual(restored.height, 1_410, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testResolvedWindowFrameClampsWhenDisplayGeometryChangesEvenWithSameDisplayID() {
|
||||
let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410)
|
||||
let savedDisplay = SessionDisplaySnapshot(
|
||||
displayID: 2,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410)
|
||||
)
|
||||
let resizedDisplay = AppDelegate.SessionDisplayGeometry(
|
||||
displayID: 2,
|
||||
frame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_050)
|
||||
)
|
||||
|
||||
let restored = AppDelegate.resolvedWindowFrame(
|
||||
from: savedFrame,
|
||||
display: savedDisplay,
|
||||
availableDisplays: [resizedDisplay],
|
||||
fallbackDisplay: resizedDisplay
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restored)
|
||||
guard let restored else { return }
|
||||
XCTAssertTrue(resizedDisplay.visibleFrame.contains(restored))
|
||||
XCTAssertNotEqual(restored.minX, 1_303, "Changed display geometry should clamp/remap frame")
|
||||
XCTAssertNotEqual(restored.minY, -90, "Changed display geometry should clamp/remap frame")
|
||||
}
|
||||
|
||||
func testResolvedSnapshotTerminalScrollbackPrefersCaptured() {
|
||||
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: "captured-value",
|
||||
fallbackScrollback: "fallback-value"
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved, "captured-value")
|
||||
}
|
||||
|
||||
func testResolvedSnapshotTerminalScrollbackFallsBackWhenCaptureMissing() {
|
||||
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: nil,
|
||||
fallbackScrollback: "fallback-value"
|
||||
)
|
||||
|
||||
XCTAssertEqual(resolved, "fallback-value")
|
||||
}
|
||||
|
||||
func testResolvedSnapshotTerminalScrollbackTruncatesFallback() {
|
||||
let oversizedFallback = String(
|
||||
repeating: "x",
|
||||
count: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + 37
|
||||
)
|
||||
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: nil,
|
||||
fallbackScrollback: oversizedFallback
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
resolved?.count,
|
||||
SessionPersistencePolicy.maxScrollbackCharactersPerTerminal
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSnapshot(version: Int) -> AppSessionSnapshot {
|
||||
let workspace = SessionWorkspaceSnapshot(
|
||||
processTitle: "Terminal",
|
||||
customTitle: "Restored",
|
||||
customColor: nil,
|
||||
isPinned: true,
|
||||
currentDirectory: "/tmp",
|
||||
focusedPanelId: nil,
|
||||
layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)),
|
||||
panels: [],
|
||||
statusEntries: [],
|
||||
logEntries: [],
|
||||
progress: nil,
|
||||
gitBranch: nil
|
||||
)
|
||||
|
||||
let tabManager = SessionTabManagerSnapshot(
|
||||
selectedWorkspaceIndex: 0,
|
||||
workspaces: [workspace]
|
||||
)
|
||||
|
||||
let window = SessionWindowSnapshot(
|
||||
frame: SessionRectSnapshot(x: 10, y: 20, width: 900, height: 700),
|
||||
display: SessionDisplaySnapshot(
|
||||
displayID: 42,
|
||||
frame: SessionRectSnapshot(x: 0, y: 0, width: 1920, height: 1200),
|
||||
visibleFrame: SessionRectSnapshot(x: 0, y: 25, width: 1920, height: 1175)
|
||||
),
|
||||
tabManager: tabManager,
|
||||
sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240)
|
||||
)
|
||||
|
||||
return AppSessionSnapshot(
|
||||
version: version,
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
windows: [window]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
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]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -546,6 +546,68 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
func testCtrlDEarlyDuringSplitStartupKeepsWindowOpen() {
|
||||
let attempts = 12
|
||||
for attempt in 1...attempts {
|
||||
let app = XCUIApplication()
|
||||
let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-ctrl-\(UUID().uuidString).json"
|
||||
try? FileManager.default.removeItem(atPath: dataPath)
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] = "lr"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_d"
|
||||
app.launch()
|
||||
app.activate()
|
||||
defer { app.terminate() }
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForAnyJSON(atPath: dataPath, timeout: 12.0),
|
||||
"Attempt \(attempt): expected early Ctrl+D setup data at \(dataPath)"
|
||||
)
|
||||
guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else {
|
||||
XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])")
|
||||
return
|
||||
}
|
||||
|
||||
if let setupError = done["setupError"], !setupError.isEmpty {
|
||||
XCTFail("Attempt \(attempt): setup failed: \(setupError)")
|
||||
return
|
||||
}
|
||||
|
||||
let workspaceCountAfter = Int(done["workspaceCountAfter"] ?? "") ?? -1
|
||||
let panelCountAfter = Int(done["panelCountAfter"] ?? "") ?? -1
|
||||
let closedWorkspace = (done["closedWorkspace"] ?? "") == "1"
|
||||
let timedOut = (done["timedOut"] ?? "") == "1"
|
||||
let triggerMode = done["autoTriggerMode"] ?? ""
|
||||
let exitPanelId = done["exitPanelId"] ?? ""
|
||||
let workspaceId = done["workspaceId"] ?? ""
|
||||
let probeSurfaceId = done["probeShowChildExitedSurfaceId"] ?? ""
|
||||
let probeTabId = done["probeShowChildExitedTabId"] ?? ""
|
||||
|
||||
XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+D timed out. data=\(done)")
|
||||
XCTAssertEqual(triggerMode, "strict_early_ctrl_d", "Attempt \(attempt): expected strict early Ctrl+D trigger mode. data=\(done)")
|
||||
XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+D. data=\(done)")
|
||||
XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+D. data=\(done)")
|
||||
XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+D. data=\(done)")
|
||||
if let showChildExitedCount = Int(done["probeShowChildExitedCount"] ?? "") {
|
||||
XCTAssertEqual(showChildExitedCount, 1, "Attempt \(attempt): expected exactly one SHOW_CHILD_EXITED callback for one early Ctrl+D. data=\(done)")
|
||||
}
|
||||
if !exitPanelId.isEmpty, !probeSurfaceId.isEmpty {
|
||||
XCTAssertEqual(probeSurfaceId, exitPanelId, "Attempt \(attempt): SHOW_CHILD_EXITED should target the split opened by Cmd+D. data=\(done)")
|
||||
}
|
||||
if !workspaceId.isEmpty, !probeTabId.isEmpty {
|
||||
XCTAssertEqual(probeTabId, workspaceId, "Attempt \(attempt): SHOW_CHILD_EXITED should resolve to the active workspace. data=\(done)")
|
||||
}
|
||||
XCTAssertTrue(
|
||||
waitForWindowCount(app: app, atLeast: 1, timeout: 2.0),
|
||||
"Attempt \(attempt): app window should remain open after early Ctrl+D. data=\(done)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
|
|
|
|||
|
|
@ -35,4 +35,31 @@ final class SidebarResizeUITests: XCTestCase {
|
|||
XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left")
|
||||
XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset")
|
||||
}
|
||||
|
||||
func testSidebarResizerHasMaximumWidthCap() {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.waitForExistence(timeout: 5.0))
|
||||
|
||||
let elements = app.descendants(matching: .any)
|
||||
let resizer = elements["SidebarResizer"]
|
||||
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
|
||||
|
||||
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||
let farRight = start.withOffset(CGVector(dx: 5000, dy: 0))
|
||||
start.press(forDuration: 0.1, thenDragTo: farRight)
|
||||
|
||||
let windowFrame = window.frame
|
||||
let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX)
|
||||
let minimumExpectedRemaining = windowFrame.width * 0.45
|
||||
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
remainingWidth,
|
||||
minimumExpectedRemaining,
|
||||
"Expected sidebar max-width clamp to leave substantial terminal width. " +
|
||||
"remaining=\(remainingWidth), window=\(windowFrame.width)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
|
||||
// UI runners can adjust wall clock time mid-test; use monotonic uptime for polling deadlines.
|
||||
private func pollUntil(
|
||||
timeout: TimeInterval,
|
||||
pollInterval: TimeInterval = 0.05,
|
||||
condition: () -> Bool
|
||||
) -> Bool {
|
||||
let start = ProcessInfo.processInfo.systemUptime
|
||||
while true {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
|
||||
return false
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
|
||||
}
|
||||
}
|
||||
|
||||
final class UpdatePillUITests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
|
@ -131,25 +149,28 @@ final class UpdatePillUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.windows.count >= count { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
pollUntil(timeout: timeout) {
|
||||
app.windows.count >= count
|
||||
}
|
||||
return app.windows.count >= count
|
||||
}
|
||||
|
||||
private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
let pollInterval: TimeInterval = 0.05
|
||||
var size = element.frame.size
|
||||
while Date() < deadline {
|
||||
var exists = element.exists
|
||||
var hittable = element.isHittable
|
||||
|
||||
let visible = pollUntil(timeout: timeout, pollInterval: pollInterval) {
|
||||
size = element.frame.size
|
||||
if size.width > 20 && size.height > 10 {
|
||||
return
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
exists = element.exists
|
||||
hittable = element.isHittable
|
||||
return size.width > 20 && size.height > 10
|
||||
}
|
||||
if !visible {
|
||||
XCTFail(
|
||||
"Expected UpdatePill to have visible size, got \(size), exists=\(exists), hittable=\(hittable)"
|
||||
)
|
||||
}
|
||||
XCTFail("Expected UpdatePill to have visible size, got \(size)")
|
||||
}
|
||||
|
||||
private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) {
|
||||
|
|
@ -197,12 +218,14 @@ final class UpdatePillUITests: XCTestCase {
|
|||
|
||||
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
|
||||
app.launch()
|
||||
let deadline = Date().addingTimeInterval(activateTimeout)
|
||||
while Date() < deadline, app.state != .runningForeground {
|
||||
let activated = pollUntil(timeout: activateTimeout) {
|
||||
guard app.state != .runningForeground else {
|
||||
return true
|
||||
}
|
||||
app.activate()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return app.state == .runningForeground
|
||||
}
|
||||
if app.state != .runningForeground {
|
||||
if !activated {
|
||||
app.activate()
|
||||
}
|
||||
}
|
||||
|
|
@ -293,40 +316,32 @@ final class TitlebarShortcutHintsUITests: XCTestCase {
|
|||
app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"]
|
||||
app.launch()
|
||||
|
||||
let deadline = Date().addingTimeInterval(2.0)
|
||||
while Date() < deadline, app.state != .runningForeground {
|
||||
_ = pollUntil(timeout: 2.0) {
|
||||
guard app.state != .runningForeground else {
|
||||
return true
|
||||
}
|
||||
app.activate()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return app.state == .runningForeground
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.windows.count >= count { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
pollUntil(timeout: timeout) {
|
||||
app.windows.count >= count
|
||||
}
|
||||
return app.windows.count >= count
|
||||
}
|
||||
|
||||
private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
pollUntil(timeout: timeout) {
|
||||
if element.exists {
|
||||
let frame = element.frame
|
||||
if frame.width > 1, frame.height > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return false
|
||||
}
|
||||
|
||||
if element.exists {
|
||||
let frame = element.frame
|
||||
return frame.width > 1 && frame.height > 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
|
|
@ -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`.
|
||||
- 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.
|
||||
|
|
|
|||
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())
|
||||
87
tests/test_browser_eval_cli_output_regression.py
Normal file
87
tests/test_browser_eval_cli_output_regression.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/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")
|
||||
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())
|
||||
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 '
|
||||
/^ ui-tests:/ { in_ui_tests=1; next }
|
||||
in_ui_tests && /^ [^[:space:]]/ { in_ui_tests=0 }
|
||||
in_ui_tests && /runs-on: self-hosted/ { saw_self_hosted=1 }
|
||||
in_ui_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: ui-tests block must keep both self-hosted and fork guard"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: ui-tests self-hosted fork guard is present"
|
||||
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())
|
||||
126
tests/test_command_palette_update_commands.py
Executable file
126
tests/test_command_palette_update_commands.py
Executable file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for command-palette update command wiring."""
|
||||
|
||||
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,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def expect_regex(content: str, pattern: str, message: str, failures: list[str]) -> None:
|
||||
if re.search(pattern, content, flags=re.DOTALL) is None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
content_view_path = repo_root / "Sources" / "ContentView.swift"
|
||||
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
|
||||
controller_path = repo_root / "Sources" / "Update" / "UpdateController.swift"
|
||||
|
||||
missing_paths = [
|
||||
str(path)
|
||||
for path in [content_view_path, app_delegate_path, controller_path]
|
||||
if not path.exists()
|
||||
]
|
||||
if missing_paths:
|
||||
print("Missing expected files:")
|
||||
for path in missing_paths:
|
||||
print(f" - {path}")
|
||||
return 1
|
||||
|
||||
content_view = read_text(content_view_path)
|
||||
app_delegate = read_text(app_delegate_path)
|
||||
controller = read_text(controller_path)
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'static\s+let\s+updateHasAvailable\s*=\s*"update\.hasAvailable"',
|
||||
"Missing `CommandPaletteContextKeys.updateHasAvailable`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'if\s+case\s+\.updateAvailable\s*=\s*updateViewModel\.effectiveState\s*\{\s*snapshot\.setBool\(CommandPaletteContextKeys\.updateHasAvailable,\s*true\)\s*\}',
|
||||
"Command palette context no longer tracks update-available state",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\].*?when:\s*\{\s*\$0\.bool\(CommandPaletteContextKeys\.updateHasAvailable\)\s*\}',
|
||||
"Missing or incomplete `palette.applyUpdateIfAvailable` contribution visibility gating",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'commandId:\s*"palette\.attemptUpdate".*?title:\s*constant\("Attempt Update"\).*?keywords:\s*\[[^\]]*"attempt"[^\]]*"check"[^\]]*"update"[^\]]*\]',
|
||||
"Missing or incomplete `palette.attemptUpdate` contribution",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'registry\.register\(commandId:\s*"palette\.applyUpdateIfAvailable"\)\s*\{\s*AppDelegate\.shared\?\.applyUpdateIfAvailable\(nil\)\s*\}',
|
||||
"Missing handler registration for `palette.applyUpdateIfAvailable`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'registry\.register\(commandId:\s*"palette\.attemptUpdate"\)\s*\{\s*AppDelegate\.shared\?\.attemptUpdate\(nil\)\s*\}',
|
||||
"Missing handler registration for `palette.attemptUpdate`",
|
||||
failures,
|
||||
)
|
||||
|
||||
expect_regex(
|
||||
app_delegate,
|
||||
r'@objc\s+func\s+applyUpdateIfAvailable\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.installUpdate\(\)\s*\}',
|
||||
"`AppDelegate.applyUpdateIfAvailable` is missing or does not call `updateController.installUpdate()`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
app_delegate,
|
||||
r'@objc\s+func\s+attemptUpdate\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.attemptUpdate\(\)\s*\}',
|
||||
"`AppDelegate.attemptUpdate` is missing or does not call `updateController.attemptUpdate()`",
|
||||
failures,
|
||||
)
|
||||
|
||||
expect_regex(
|
||||
controller,
|
||||
r'func\s+attemptUpdate\(\)\s*\{',
|
||||
"`UpdateController.attemptUpdate()` is missing",
|
||||
failures,
|
||||
)
|
||||
if "state.confirm()" not in controller:
|
||||
failures.append("`UpdateController.attemptUpdate()` no longer auto-confirms update installation")
|
||||
if "checkForUpdates()" not in controller:
|
||||
failures.append("`UpdateController.attemptUpdate()` no longer triggers a check before install")
|
||||
|
||||
if failures:
|
||||
print("FAIL: command-palette update command regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: command-palette update commands expose apply + attempt wiring")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
64
tests/test_focus_panel_reentrant_guard_regression.py
Normal file
64
tests/test_focus_panel_reentrant_guard_regression.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for re-entrant terminal focus guard.
|
||||
|
||||
Guards the fix for split-drag focus churn where:
|
||||
becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects
|
||||
could repeatedly re-enter and spike CPU.
|
||||
"""
|
||||
|
||||
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 main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
workspace_path = root / "Sources" / "Workspace.swift"
|
||||
workspace_source = workspace_path.read_text(encoding="utf-8")
|
||||
|
||||
required_workspace_snippets = [
|
||||
"enum FocusPanelTrigger {",
|
||||
"case terminalFirstResponder",
|
||||
"trigger: FocusPanelTrigger = .standard",
|
||||
"let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged",
|
||||
"if let targetPaneId, !shouldSuppressReentrantRefocus {",
|
||||
"reason=firstResponderAlreadyConverged",
|
||||
]
|
||||
for snippet in required_workspace_snippets:
|
||||
if snippet not in workspace_source:
|
||||
failures.append(f"Workspace focus guard missing snippet: {snippet}")
|
||||
|
||||
workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift"
|
||||
workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8")
|
||||
focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)"
|
||||
if focus_callback_snippet not in workspace_content_view_source:
|
||||
failures.append(
|
||||
"WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: focus-panel re-entrant guard regression checks failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: focus-panel re-entrant guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -9,6 +9,7 @@ This test checks for:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s
|
|||
return violations
|
||||
|
||||
|
||||
def check_command_palette_caret_tint(repo_root: Path) -> List[str]:
|
||||
"""Ensure command palette text inputs keep a white caret tint."""
|
||||
content_view = repo_root / "Sources" / "ContentView.swift"
|
||||
if not content_view.exists():
|
||||
return [f"Missing expected file: {content_view}"]
|
||||
|
||||
try:
|
||||
content = content_view.read_text()
|
||||
except Exception as e:
|
||||
return [f"Could not read {content_view}: {e}"]
|
||||
|
||||
checks = [
|
||||
(
|
||||
"search input",
|
||||
r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteSearchFocused\)",
|
||||
),
|
||||
(
|
||||
"rename input",
|
||||
r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteRenameFocused\)",
|
||||
),
|
||||
]
|
||||
|
||||
violations: List[str] = []
|
||||
for label, pattern in checks:
|
||||
match = re.search(pattern, content, flags=re.DOTALL)
|
||||
if not match:
|
||||
violations.append(
|
||||
f"Could not locate command palette {label} TextField block in Sources/ContentView.swift"
|
||||
)
|
||||
continue
|
||||
|
||||
body = match.group("body")
|
||||
if ".tint(.white)" not in body:
|
||||
violations.append(
|
||||
f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift"
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the lint checks."""
|
||||
repo_root = get_repo_root()
|
||||
|
|
@ -102,15 +145,18 @@ def main():
|
|||
print(f"Checking {len(swift_files)} Swift files for performance issues...")
|
||||
|
||||
# Check for auto-updating Text styles
|
||||
violations = check_autoupdating_text_styles(swift_files)
|
||||
style_violations = check_autoupdating_text_styles(swift_files)
|
||||
tint_violations = check_command_palette_caret_tint(repo_root)
|
||||
has_failures = False
|
||||
|
||||
if violations:
|
||||
if style_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Auto-updating Text styles found")
|
||||
print("=" * 60)
|
||||
print("These patterns cause continuous SwiftUI view updates and high CPU usage:")
|
||||
print()
|
||||
|
||||
for file_path, line_num, line in violations:
|
||||
for file_path, line_num, line in style_violations:
|
||||
rel_path = file_path.relative_to(repo_root)
|
||||
print(f" {rel_path}:{line_num}")
|
||||
print(f" {line}")
|
||||
|
|
@ -120,9 +166,23 @@ def main():
|
|||
print(" Instead of: Text(date, style: .time)")
|
||||
print(" Use: Text(date.formatted(date: .omitted, time: .shortened))")
|
||||
print()
|
||||
|
||||
if tint_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Command palette caret tint drifted")
|
||||
print("=" * 60)
|
||||
print("The command palette search and rename text fields must keep a white caret:")
|
||||
print()
|
||||
for message in tint_violations:
|
||||
print(f" {message}")
|
||||
print()
|
||||
print("FIX: Set command palette TextField tint modifiers to `.white`.")
|
||||
print()
|
||||
|
||||
if has_failures:
|
||||
return 1
|
||||
|
||||
print("✅ No auto-updating Text style patterns found")
|
||||
print("✅ No linted SwiftUI pattern regressions found")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
333
tests/test_open_wrapper.py
Executable file
333
tests/test_open_wrapper.py
Executable file
|
|
@ -0,0 +1,333 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression tests for Resources/bin/open.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_WRAPPER = ROOT / "Resources" / "bin" / "open"
|
||||
|
||||
|
||||
def make_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def read_log(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def run_wrapper(
|
||||
*,
|
||||
args: list[str],
|
||||
intercept_setting: str | None,
|
||||
legacy_open_setting: str | None = None,
|
||||
whitelist: str | None,
|
||||
fail_urls: list[str] | None = None,
|
||||
) -> tuple[list[str], list[str], int, str]:
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-open-wrapper-test-") as td:
|
||||
tmp = Path(td)
|
||||
wrapper = tmp / "open"
|
||||
shutil.copy2(SOURCE_WRAPPER, wrapper)
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
open_log = tmp / "open.log"
|
||||
cmux_log = tmp / "cmux.log"
|
||||
system_open = tmp / "system-open"
|
||||
defaults = tmp / "defaults"
|
||||
cmux = tmp / "cmux"
|
||||
|
||||
make_executable(
|
||||
system_open,
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\n' "$*" >> "$FAKE_OPEN_LOG"
|
||||
""",
|
||||
)
|
||||
|
||||
make_executable(
|
||||
defaults,
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" != "read" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
key="${3:-}"
|
||||
case "$key" in
|
||||
browserInterceptTerminalOpenCommandInCmuxBrowser)
|
||||
if [[ "${FAKE_DEFAULTS_INTERCEPT_OPEN+x}" == "x" ]]; then
|
||||
printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
browserOpenTerminalLinksInCmuxBrowser)
|
||||
if [[ "${FAKE_DEFAULTS_LEGACY_OPEN+x}" == "x" ]]; then
|
||||
printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
browserHostWhitelist)
|
||||
if [[ "${FAKE_DEFAULTS_WHITELIST+x}" == "x" ]]; then
|
||||
printf '%s' "$FAKE_DEFAULTS_WHITELIST"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
)
|
||||
|
||||
make_executable(
|
||||
cmux,
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\n' "$*" >> "$FAKE_CMUX_LOG"
|
||||
url=""
|
||||
for arg in "$@"; do
|
||||
url="$arg"
|
||||
done
|
||||
if [[ -n "${FAKE_CMUX_FAIL_URLS:-}" ]]; then
|
||||
IFS=',' read -r -a failures <<< "$FAKE_CMUX_FAIL_URLS"
|
||||
for fail_url in "${failures[@]}"; do
|
||||
if [[ "$url" == "$fail_url" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
""",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock"
|
||||
env["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.test"
|
||||
env["CMUX_OPEN_WRAPPER_SYSTEM_OPEN"] = str(system_open)
|
||||
env["CMUX_OPEN_WRAPPER_DEFAULTS"] = str(defaults)
|
||||
env["FAKE_OPEN_LOG"] = str(open_log)
|
||||
env["FAKE_CMUX_LOG"] = str(cmux_log)
|
||||
|
||||
if intercept_setting is None:
|
||||
env.pop("FAKE_DEFAULTS_INTERCEPT_OPEN", None)
|
||||
else:
|
||||
env["FAKE_DEFAULTS_INTERCEPT_OPEN"] = intercept_setting
|
||||
|
||||
if legacy_open_setting is None:
|
||||
env.pop("FAKE_DEFAULTS_LEGACY_OPEN", None)
|
||||
else:
|
||||
env["FAKE_DEFAULTS_LEGACY_OPEN"] = legacy_open_setting
|
||||
|
||||
if whitelist is None:
|
||||
env.pop("FAKE_DEFAULTS_WHITELIST", None)
|
||||
else:
|
||||
env["FAKE_DEFAULTS_WHITELIST"] = whitelist
|
||||
|
||||
if fail_urls:
|
||||
env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls)
|
||||
else:
|
||||
env.pop("FAKE_CMUX_FAIL_URLS", None)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/bash", str(wrapper), *args],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
return read_log(open_log), read_log(cmux_log), result.returncode, result.stderr.strip()
|
||||
|
||||
|
||||
def expect(condition: bool, message: str, failures: list[str]) -> None:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def test_toggle_disabled_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="0",
|
||||
whitelist="",
|
||||
)
|
||||
expect(code == 0, f"toggle off: wrapper exited {code}: {stderr}", failures)
|
||||
expect(cmux_log == [], f"toggle off: cmux should not be called, got {cmux_log}", failures)
|
||||
expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures)
|
||||
|
||||
|
||||
def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting=" FaLsE ",
|
||||
whitelist="",
|
||||
)
|
||||
expect(code == 0, f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", failures)
|
||||
expect(
|
||||
cmux_log == [],
|
||||
f"toggle off (case-insensitive): cmux should not be called, got {cmux_log}",
|
||||
failures,
|
||||
)
|
||||
expect(
|
||||
open_log == [url],
|
||||
f"toggle off (case-insensitive): expected system open [{url}], got {open_log}",
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
def test_whitelist_miss_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="localhost\n127.0.0.1",
|
||||
)
|
||||
expect(code == 0, f"whitelist miss: wrapper exited {code}: {stderr}", failures)
|
||||
expect(cmux_log == [], f"whitelist miss: cmux should not be called, got {cmux_log}", failures)
|
||||
expect(open_log == [url], f"whitelist miss: expected system open [{url}], got {open_log}", failures)
|
||||
|
||||
|
||||
def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None:
|
||||
url = "https://api.example.com/path?q=1"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="*.example.com",
|
||||
)
|
||||
expect(code == 0, f"whitelist match: wrapper exited {code}: {stderr}", failures)
|
||||
expect(open_log == [], f"whitelist match: system open should not be called, got {open_log}", failures)
|
||||
expect(cmux_log == [f"browser open {url}"], f"whitelist match: unexpected cmux log {cmux_log}", failures)
|
||||
|
||||
|
||||
def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None:
|
||||
good = "https://api.example.com"
|
||||
failed = "https://fail.example.com"
|
||||
external = "https://outside.test"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[good, failed, external],
|
||||
intercept_setting="1",
|
||||
whitelist="*.example.com",
|
||||
fail_urls=[failed],
|
||||
)
|
||||
expect(code == 0, f"partial failure: wrapper exited {code}: {stderr}", failures)
|
||||
expect(
|
||||
cmux_log == [f"browser open {good}", f"browser open {failed}"],
|
||||
f"partial failure: cmux log mismatch {cmux_log}",
|
||||
failures,
|
||||
)
|
||||
expect(
|
||||
open_log == [f"{failed} {external}"],
|
||||
f"partial failure: expected fallback for failed/external only, got {open_log}",
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting=None,
|
||||
legacy_open_setting="0",
|
||||
whitelist="",
|
||||
)
|
||||
expect(code == 0, f"legacy fallback: wrapper exited {code}: {stderr}", failures)
|
||||
expect(cmux_log == [], f"legacy fallback: cmux should not be called, got {cmux_log}", failures)
|
||||
expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures)
|
||||
|
||||
|
||||
def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str]) -> None:
|
||||
url = "https://example.com"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting=None,
|
||||
legacy_open_setting=" Off ",
|
||||
whitelist="",
|
||||
)
|
||||
expect(code == 0, f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", failures)
|
||||
expect(
|
||||
cmux_log == [],
|
||||
f"legacy fallback (case-insensitive): cmux should not be called, got {cmux_log}",
|
||||
failures,
|
||||
)
|
||||
expect(
|
||||
open_log == [url],
|
||||
f"legacy fallback (case-insensitive): expected system open [{url}], got {open_log}",
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None:
|
||||
url = "HTTPS://api.example.com/path?q=1"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="*.example.com",
|
||||
)
|
||||
expect(code == 0, f"uppercase scheme: wrapper exited {code}: {stderr}", failures)
|
||||
expect(open_log == [], f"uppercase scheme: system open should not be called, got {open_log}", failures)
|
||||
expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures)
|
||||
|
||||
|
||||
def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None:
|
||||
url = "https://xn--bcher-kva.example/path"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="bücher.example",
|
||||
)
|
||||
expect(code == 0, f"unicode whitelist: wrapper exited {code}: {stderr}", failures)
|
||||
expect(open_log == [], f"unicode whitelist: system open should not be called, got {open_log}", failures)
|
||||
expect(cmux_log == [f"browser open {url}"], f"unicode whitelist: unexpected cmux log {cmux_log}", failures)
|
||||
|
||||
|
||||
def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None:
|
||||
url = "https://bücher.example/path"
|
||||
open_log, cmux_log, code, stderr = run_wrapper(
|
||||
args=[url],
|
||||
intercept_setting="1",
|
||||
whitelist="xn--bcher-kva.example",
|
||||
)
|
||||
expect(code == 0, f"punycode whitelist: wrapper exited {code}: {stderr}", failures)
|
||||
expect(open_log == [], f"punycode whitelist: system open should not be called, got {open_log}", failures)
|
||||
expect(cmux_log == [f"browser open {url}"], f"punycode whitelist: unexpected cmux log {cmux_log}", failures)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures: list[str] = []
|
||||
test_toggle_disabled_passthrough(failures)
|
||||
test_toggle_disabled_case_insensitive_passthrough(failures)
|
||||
test_whitelist_miss_passthrough(failures)
|
||||
test_whitelist_match_routes_to_cmux(failures)
|
||||
test_partial_failures_only_fallback_failed_urls(failures)
|
||||
test_legacy_toggle_fallback_passthrough(failures)
|
||||
test_legacy_toggle_fallback_case_insensitive_passthrough(failures)
|
||||
test_uppercase_scheme_routes_to_cmux(failures)
|
||||
test_unicode_whitelist_matches_punycode_url(failures)
|
||||
test_punycode_whitelist_matches_unicode_url(failures)
|
||||
|
||||
if failures:
|
||||
print("open wrapper regression tests failed:")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("open wrapper regression tests passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: unfocused workspace scrollback must persist across relaunchs in multi-window setups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cmux import cmux
|
||||
|
||||
|
||||
def _bundle_id(app_path: Path) -> str:
|
||||
info_path = app_path / "Contents" / "Info.plist"
|
||||
if not info_path.exists():
|
||||
raise RuntimeError(f"Missing Info.plist at {info_path}")
|
||||
with info_path.open("rb") as f:
|
||||
info = plistlib.load(f)
|
||||
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
|
||||
if not bundle_id:
|
||||
raise RuntimeError("Missing CFBundleIdentifier")
|
||||
return bundle_id
|
||||
|
||||
|
||||
def _snapshot_path(bundle_id: str) -> Path:
|
||||
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
|
||||
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json"
|
||||
|
||||
|
||||
def _sanitize_tag_slug(raw: str) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]+", "-", (raw or "").strip().lower())
|
||||
cleaned = re.sub(r"-+", "-", cleaned).strip("-")
|
||||
return cleaned or "agent"
|
||||
|
||||
|
||||
def _socket_candidates(app_path: Path, preferred: Path) -> list[Path]:
|
||||
candidates = [preferred]
|
||||
app_name = app_path.stem
|
||||
prefix = "cmux DEV "
|
||||
if app_name.startswith(prefix):
|
||||
tag = app_name[len(prefix):]
|
||||
slug = _sanitize_tag_slug(tag)
|
||||
candidates.append(Path(f"/tmp/cmux-debug-{slug}.sock"))
|
||||
deduped: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
key = str(candidate)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(candidate)
|
||||
return deduped
|
||||
|
||||
|
||||
def _socket_reachable(socket_path: Path) -> bool:
|
||||
if not socket_path.exists():
|
||||
return False
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(0.3)
|
||||
sock.connect(str(socket_path))
|
||||
sock.sendall(b"ping\n")
|
||||
data = sock.recv(1024)
|
||||
return b"PONG" in data
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _wait_for_socket(candidates: list[Path], timeout: float = 20.0) -> Path:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for candidate in candidates:
|
||||
if _socket_reachable(candidate):
|
||||
return candidate
|
||||
time.sleep(0.2)
|
||||
joined = ", ".join(str(path) for path in candidates)
|
||||
raise RuntimeError(f"Socket did not become reachable: {joined}")
|
||||
|
||||
|
||||
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if not _socket_reachable(socket_path):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
|
||||
|
||||
|
||||
def _kill_existing(app_path: Path) -> None:
|
||||
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
|
||||
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _launch(app_path: Path, preferred_socket_path: Path) -> Path:
|
||||
try:
|
||||
preferred_socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
subprocess.run(
|
||||
[
|
||||
"open",
|
||||
"-na",
|
||||
str(app_path),
|
||||
"--env",
|
||||
f"CMUX_SOCKET_PATH={preferred_socket_path}",
|
||||
"--env",
|
||||
"CMUX_ALLOW_SOCKET_OVERRIDE=1",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
resolved_socket_path = _wait_for_socket(_socket_candidates(app_path, preferred_socket_path))
|
||||
time.sleep(1.5)
|
||||
return resolved_socket_path
|
||||
|
||||
|
||||
def _quit(bundle_id: str, socket_path: Path) -> None:
|
||||
subprocess.run(
|
||||
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
_wait_for_socket_closed(socket_path)
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
time.sleep(0.8)
|
||||
|
||||
|
||||
def _connect(socket_path: Path) -> cmux:
|
||||
client = cmux(socket_path=str(socket_path))
|
||||
client.connect()
|
||||
if not client.ping():
|
||||
raise RuntimeError("ping failed")
|
||||
return client
|
||||
|
||||
|
||||
def _read_scrollback(client: cmux) -> str:
|
||||
return client._send_command("read_screen --scrollback")
|
||||
|
||||
|
||||
def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if marker in _read_scrollback(client):
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def _consume_visible_markers(client: cmux, remaining: set[str], timeout: float = 4.0) -> None:
|
||||
if not remaining:
|
||||
return
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline and remaining:
|
||||
text = _read_scrollback(client)
|
||||
matched = [marker for marker in remaining if marker in text]
|
||||
if matched:
|
||||
for marker in matched:
|
||||
remaining.discard(marker)
|
||||
if not remaining:
|
||||
return
|
||||
time.sleep(0.25)
|
||||
|
||||
|
||||
def _ensure_workspaces(client: cmux, count: int) -> None:
|
||||
while len(client.list_workspaces()) < count:
|
||||
client.new_workspace()
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def _list_windows(client: cmux) -> list[str]:
|
||||
response = client._send_command("list_windows")
|
||||
if response == "No windows":
|
||||
return []
|
||||
window_ids: list[str] = []
|
||||
for line in response.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.lstrip("* ").split(" ", 2)
|
||||
if len(parts) >= 2:
|
||||
window_ids.append(parts[1])
|
||||
return window_ids
|
||||
|
||||
|
||||
def _new_window(client: cmux) -> str:
|
||||
response = client._send_command("new_window")
|
||||
if not response.startswith("OK "):
|
||||
raise RuntimeError(f"new_window failed: {response}")
|
||||
return response.split(" ", 1)[1].strip()
|
||||
|
||||
|
||||
def _focus_window(client: cmux, window_id: str) -> None:
|
||||
response = client._send_command(f"focus_window {window_id}")
|
||||
if response != "OK":
|
||||
raise RuntimeError(f"focus_window failed for {window_id}: {response}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
|
||||
if not app_path_str:
|
||||
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
|
||||
return 0
|
||||
app_path = Path(app_path_str)
|
||||
if not app_path.exists():
|
||||
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
|
||||
return 0
|
||||
|
||||
bundle_id = _bundle_id(app_path)
|
||||
snapshot = _snapshot_path(bundle_id)
|
||||
# Keep the override path short enough for Darwin's Unix socket path limit.
|
||||
bundle_suffix = re.sub(r"[^A-Za-z0-9]", "", bundle_id)[-16:] or "bundle"
|
||||
socket_path = Path(f"/tmp/cmux-mw-restore-{bundle_suffix}.sock")
|
||||
|
||||
markers = {
|
||||
"w1_ws0": "CMUX_MW_RESTORE_W1_WS0",
|
||||
"w1_ws1": "CMUX_MW_RESTORE_W1_WS1",
|
||||
"w2_ws0": "CMUX_MW_RESTORE_W2_WS0",
|
||||
"w2_ws1": "CMUX_MW_RESTORE_W2_WS1",
|
||||
}
|
||||
failures: list[str] = []
|
||||
|
||||
_kill_existing(app_path)
|
||||
snapshot.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
# Launch 1: create 2 windows x 2 workspaces; write markers.
|
||||
socket_path = _launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
# Window 1 setup.
|
||||
_ensure_workspaces(client, 2)
|
||||
client.select_workspace(0)
|
||||
client.send(f"echo {markers['w1_ws0']}\n")
|
||||
if not _wait_for_marker(client, markers["w1_ws0"]):
|
||||
failures.append("missing marker for window1 workspace0 during setup")
|
||||
client.select_workspace(1)
|
||||
client.send(f"echo {markers['w1_ws1']}\n")
|
||||
if not _wait_for_marker(client, markers["w1_ws1"]):
|
||||
failures.append("missing marker for window1 workspace1 during setup")
|
||||
client.select_workspace(0) # leave workspace 1 unfocused in window 1
|
||||
|
||||
# Window 2 setup.
|
||||
_new_window(client)
|
||||
time.sleep(0.5)
|
||||
_ensure_workspaces(client, 2)
|
||||
client.select_workspace(0)
|
||||
client.send(f"echo {markers['w2_ws0']}\n")
|
||||
if not _wait_for_marker(client, markers["w2_ws0"]):
|
||||
failures.append("missing marker for window2 workspace0 during setup")
|
||||
client.select_workspace(1)
|
||||
client.send(f"echo {markers['w2_ws1']}\n")
|
||||
if not _wait_for_marker(client, markers["w2_ws1"]):
|
||||
failures.append("missing marker for window2 workspace1 during setup")
|
||||
client.select_workspace(0) # leave workspace 1 unfocused in window 2
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
|
||||
# Launch 2: immediate quit without focusing unfocused workspaces.
|
||||
socket_path = _launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
window_ids = _list_windows(client)
|
||||
if len(window_ids) < 2:
|
||||
failures.append(f"expected >=2 windows after first relaunch, got {len(window_ids)}")
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
|
||||
# Launch 3: verify all markers still present across windows/workspaces.
|
||||
socket_path = _launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
window_ids = _list_windows(client)
|
||||
if len(window_ids) < 2:
|
||||
failures.append(f"expected >=2 windows after second relaunch, got {len(window_ids)}")
|
||||
|
||||
remaining = set(markers.values())
|
||||
for window_id in window_ids:
|
||||
_focus_window(client, window_id)
|
||||
time.sleep(0.3)
|
||||
workspace_count = len(client.list_workspaces())
|
||||
for idx in range(min(workspace_count, 2)):
|
||||
client.select_workspace(idx)
|
||||
_consume_visible_markers(client, remaining, timeout=6.0)
|
||||
if not remaining:
|
||||
break
|
||||
if not remaining:
|
||||
break
|
||||
|
||||
if remaining:
|
||||
failures.append(f"missing markers after second relaunch: {sorted(remaining)}")
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
finally:
|
||||
_kill_existing(app_path)
|
||||
socket_path.unlink(missing_ok=True)
|
||||
snapshot.unlink(missing_ok=True)
|
||||
|
||||
if failures:
|
||||
print("FAIL:")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: multi-window unfocused workspaces survive repeated relaunch")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
229
tests/test_session_restore_unfocused_workspace_relaunch_cycle.py
Normal file
229
tests/test_session_restore_unfocused_workspace_relaunch_cycle.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: unfocused restored workspaces must survive a second relaunch.
|
||||
|
||||
Repro for the historical bug:
|
||||
1) Launch and save workspaces with marker scrollback.
|
||||
2) Relaunch, do not focus the non-selected workspaces, then quit again.
|
||||
3) Relaunch and verify marker scrollback still exists for every workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cmux import cmux
|
||||
|
||||
|
||||
def _bundle_id(app_path: Path) -> str:
|
||||
info_path = app_path / "Contents" / "Info.plist"
|
||||
if not info_path.exists():
|
||||
raise RuntimeError(f"Missing Info.plist at {info_path}")
|
||||
with info_path.open("rb") as f:
|
||||
info = plistlib.load(f)
|
||||
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
|
||||
if not bundle_id:
|
||||
raise RuntimeError("Missing CFBundleIdentifier")
|
||||
return bundle_id
|
||||
|
||||
|
||||
def _snapshot_path(bundle_id: str) -> Path:
|
||||
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
|
||||
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json"
|
||||
|
||||
|
||||
def _socket_reachable(socket_path: Path) -> bool:
|
||||
if not socket_path.exists():
|
||||
return False
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(0.3)
|
||||
sock.connect(str(socket_path))
|
||||
sock.sendall(b"ping\n")
|
||||
data = sock.recv(1024)
|
||||
return b"PONG" in data
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if _socket_reachable(socket_path):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"Socket did not become reachable: {socket_path}")
|
||||
|
||||
|
||||
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if not _socket_reachable(socket_path):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
|
||||
|
||||
|
||||
def _kill_existing(app_path: Path) -> None:
|
||||
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
|
||||
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _launch(app_path: Path, socket_path: Path) -> None:
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
subprocess.run(
|
||||
[
|
||||
"open",
|
||||
"-na",
|
||||
str(app_path),
|
||||
"--env",
|
||||
f"CMUX_SOCKET_PATH={socket_path}",
|
||||
"--env",
|
||||
"CMUX_ALLOW_SOCKET_OVERRIDE=1",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
_wait_for_socket(socket_path)
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
def _quit(bundle_id: str, socket_path: Path) -> None:
|
||||
subprocess.run(
|
||||
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
_wait_for_socket_closed(socket_path)
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
time.sleep(0.8)
|
||||
|
||||
|
||||
def _connect(socket_path: Path) -> cmux:
|
||||
client = cmux(socket_path=str(socket_path))
|
||||
client.connect()
|
||||
if not client.ping():
|
||||
raise RuntimeError("ping failed")
|
||||
return client
|
||||
|
||||
|
||||
def _read_scrollback(client: cmux) -> str:
|
||||
return client._send_command("read_screen --scrollback")
|
||||
|
||||
|
||||
def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if marker in _read_scrollback(client):
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
|
||||
if not app_path_str:
|
||||
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
|
||||
return 0
|
||||
app_path = Path(app_path_str)
|
||||
if not app_path.exists():
|
||||
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
|
||||
return 0
|
||||
|
||||
bundle_id = _bundle_id(app_path)
|
||||
snapshot = _snapshot_path(bundle_id)
|
||||
socket_path = Path(f"/tmp/cmux-session-restore-cycle-{bundle_id.replace('.', '-')}.sock")
|
||||
|
||||
markers = [f"CMUX_RESTORE_EDGE_{i}" for i in range(3)]
|
||||
failures: list[str] = []
|
||||
|
||||
_kill_existing(app_path)
|
||||
snapshot.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
# First launch: seed three workspaces with marker scrollback.
|
||||
_launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
while len(client.list_workspaces()) < 3:
|
||||
client.new_workspace()
|
||||
time.sleep(0.3)
|
||||
|
||||
for idx, marker in enumerate(markers):
|
||||
client.select_workspace(idx)
|
||||
time.sleep(0.4)
|
||||
client.send(f"echo {marker}\n")
|
||||
if not _wait_for_marker(client, marker, timeout=6.0):
|
||||
failures.append(f"setup marker missing in workspace {idx}: {marker}")
|
||||
|
||||
# Keep selected workspace deterministic.
|
||||
client.select_workspace(1)
|
||||
time.sleep(0.3)
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
|
||||
# Second launch: do not focus unfocused workspaces. Quit immediately.
|
||||
_launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
restored = client.list_workspaces()
|
||||
if len(restored) < 3:
|
||||
failures.append(f"expected >=3 workspaces after first relaunch, got {len(restored)}")
|
||||
selected_indices = [idx for idx, _wid, _title, selected in restored if selected]
|
||||
if selected_indices != [1]:
|
||||
failures.append(f"expected selected workspace index [1], got {selected_indices}")
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
|
||||
# Third launch: every workspace should still contain its marker.
|
||||
_launch(app_path, socket_path)
|
||||
client = _connect(socket_path)
|
||||
try:
|
||||
restored = client.list_workspaces()
|
||||
if len(restored) < 3:
|
||||
failures.append(f"expected >=3 workspaces after second relaunch, got {len(restored)}")
|
||||
|
||||
for idx, marker in enumerate(markers):
|
||||
client.select_workspace(idx)
|
||||
if not _wait_for_marker(client, marker, timeout=8.0):
|
||||
tail = "\n".join(_read_scrollback(client).splitlines()[-10:])
|
||||
failures.append(
|
||||
f"workspace {idx} missing marker {marker} after second relaunch; tail:\n{tail}"
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
_quit(bundle_id, socket_path)
|
||||
finally:
|
||||
_kill_existing(app_path)
|
||||
socket_path.unlink(missing_ok=True)
|
||||
snapshot.unlink(missing_ok=True)
|
||||
|
||||
if failures:
|
||||
print("FAIL:")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: unfocused workspace scrollback survives repeated relaunch")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
62
tests/test_shell_scrollback_restore_color_replay.py
Normal file
62
tests/test_shell_scrollback_restore_color_replay.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: ANSI color escape bytes in replay content must be preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
|
||||
if not integration_script.exists():
|
||||
print(f"SKIP: missing zsh integration script at {integration_script}")
|
||||
return 0
|
||||
|
||||
base = Path("/tmp") / f"cmux_scrollback_color_replay_{os.getpid()}"
|
||||
try:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
replay_file = base / "replay.bin"
|
||||
replay_file.write_bytes(b"\x1b[31mRED\x1b[0m\n")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = str(base / "empty-bin")
|
||||
env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file)
|
||||
env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"FAIL: zsh exited non-zero rc={result.returncode}")
|
||||
if result.stderr:
|
||||
print(result.stderr.decode("utf-8", errors="replace").strip())
|
||||
return 1
|
||||
|
||||
output = (result.stdout or b"") + (result.stderr or b"")
|
||||
if b"\x1b[31mRED\x1b[0m" not in output:
|
||||
print("FAIL: ANSI color escape sequence not preserved in replay output")
|
||||
return 1
|
||||
|
||||
if replay_file.exists():
|
||||
print("FAIL: replay file was not deleted after replay")
|
||||
return 1
|
||||
|
||||
print("PASS: ANSI color escape sequence preserved during replay")
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: scrollback replay must not depend on PATH containing coreutils.
|
||||
|
||||
cmux can launch shells with PATH initially pointing at app resources. If replay
|
||||
relies on bare `cat`/`rm`, startup replay silently fails before user rc files
|
||||
restore PATH.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
|
||||
if not integration_script.exists():
|
||||
print(f"SKIP: missing zsh integration script at {integration_script}")
|
||||
return 0
|
||||
|
||||
base = Path("/tmp") / f"cmux_scrollback_restore_{os.getpid()}"
|
||||
try:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
replay_file = base / "replay.txt"
|
||||
replay_file.write_text("scrollback-line-1\nscrollback-line-2\n", encoding="utf-8")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = str(base / "empty-bin")
|
||||
env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file)
|
||||
env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"FAIL: zsh exited non-zero rc={result.returncode}")
|
||||
if result.stderr.strip():
|
||||
print(result.stderr.strip())
|
||||
return 1
|
||||
|
||||
output = (result.stdout or "") + (result.stderr or "")
|
||||
if "scrollback-line-1" not in output or "scrollback-line-2" not in output:
|
||||
print("FAIL: replay text was not printed during integration startup")
|
||||
return 1
|
||||
|
||||
if replay_file.exists():
|
||||
print("FAIL: replay file was not deleted after replay")
|
||||
return 1
|
||||
|
||||
print("PASS: scrollback replay works with minimal PATH")
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
46
tests/test_sidebar_indicator_default.py
Normal file
46
tests/test_sidebar_indicator_default.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test for the default sidebar active workspace indicator style.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
tab_manager = repo_root / "Sources" / "TabManager.swift"
|
||||
|
||||
if not tab_manager.exists():
|
||||
print(f"FAIL: Missing file {tab_manager}")
|
||||
return 1
|
||||
|
||||
content = tab_manager.read_text(encoding="utf-8")
|
||||
pattern = r"static let defaultStyle:\s*SidebarActiveTabIndicatorStyle\s*=\s*\.leftRail\b"
|
||||
|
||||
if re.search(pattern, content) is None:
|
||||
rel = tab_manager.relative_to(repo_root)
|
||||
print(f"FAIL: Expected default style `.leftRail` in {rel}")
|
||||
return 1
|
||||
|
||||
print("PASS: sidebar indicator default style is left rail")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -20,6 +20,9 @@ import subprocess
|
|||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import json
|
||||
import glob
|
||||
import plistlib
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from cmux import cmux, cmuxError
|
||||
|
|
@ -68,34 +71,169 @@ def _raw_send(sock, command: str, timeout: float = 3.0) -> str:
|
|||
return data.decode().strip()
|
||||
|
||||
|
||||
def _preferred_worktree_slug():
|
||||
env_slug = os.environ.get("CMUX_TAG") or os.environ.get("CMUX_BRANCH_SLUG")
|
||||
if env_slug:
|
||||
return env_slug.strip().lower()
|
||||
|
||||
cwd = os.getcwd()
|
||||
marker = "/worktrees/"
|
||||
if marker in cwd:
|
||||
tail = cwd.split(marker, 1)[1]
|
||||
slug = tail.split("/", 1)[0].strip().lower()
|
||||
if slug:
|
||||
return slug
|
||||
return ""
|
||||
|
||||
|
||||
def _derived_app_candidates_for_current_worktree():
|
||||
project_path = os.path.realpath(os.path.join(os.getcwd(), "GhosttyTabs.xcodeproj"))
|
||||
info_paths = glob.glob(os.path.expanduser(
|
||||
"~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*/info.plist"
|
||||
))
|
||||
matches = []
|
||||
for info_path in info_paths:
|
||||
try:
|
||||
with open(info_path, "rb") as f:
|
||||
info = plistlib.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
workspace_path = info.get("WorkspacePath")
|
||||
if not workspace_path:
|
||||
continue
|
||||
if os.path.realpath(workspace_path) != project_path:
|
||||
continue
|
||||
derived_root = os.path.dirname(info_path)
|
||||
app_path = os.path.join(derived_root, "Build/Products/Debug/cmux DEV.app")
|
||||
if os.path.exists(app_path):
|
||||
matches.append(app_path)
|
||||
return matches
|
||||
|
||||
|
||||
def _find_app():
|
||||
r = subprocess.run(
|
||||
["find", "/Users/cmux/Library/Developer/Xcode/DerivedData",
|
||||
"-path", "*/Build/Products/Debug/cmux DEV.app", "-print", "-quit"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return r.stdout.strip()
|
||||
explicit = os.environ.get("CMUX_APP_PATH")
|
||||
if explicit and os.path.exists(explicit):
|
||||
return explicit
|
||||
|
||||
preferred_slug = _preferred_worktree_slug()
|
||||
if preferred_slug:
|
||||
preferred_tmp = []
|
||||
preferred_tmp.extend(glob.glob(f"/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app"))
|
||||
preferred_tmp.extend(glob.glob(f"/private/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app"))
|
||||
preferred_tmp = [p for p in preferred_tmp if os.path.exists(p)]
|
||||
if preferred_tmp:
|
||||
preferred_tmp.sort(key=os.path.getmtime, reverse=True)
|
||||
return preferred_tmp[0]
|
||||
|
||||
direct_matches = _derived_app_candidates_for_current_worktree()
|
||||
if direct_matches:
|
||||
direct_matches.sort(key=os.path.getmtime, reverse=True)
|
||||
return direct_matches[0]
|
||||
|
||||
home = os.path.expanduser("~")
|
||||
derived_candidates = glob.glob(os.path.join(
|
||||
home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app"
|
||||
))
|
||||
tmp_candidates = []
|
||||
tmp_candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app"))
|
||||
tmp_candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app"))
|
||||
|
||||
derived_candidates = [p for p in derived_candidates if os.path.exists(p)]
|
||||
tmp_candidates = [p for p in tmp_candidates if os.path.exists(p)]
|
||||
|
||||
if preferred_slug:
|
||||
preferred_derived = [p for p in derived_candidates if preferred_slug in p.lower()]
|
||||
preferred_tmp = [p for p in tmp_candidates if preferred_slug in p.lower()]
|
||||
if preferred_derived:
|
||||
derived_candidates = preferred_derived
|
||||
if preferred_tmp:
|
||||
tmp_candidates = preferred_tmp
|
||||
|
||||
if derived_candidates:
|
||||
derived_candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return derived_candidates[0]
|
||||
|
||||
if tmp_candidates:
|
||||
tmp_candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return tmp_candidates[0]
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _find_cli(preferred_app_path: 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
|
||||
|
||||
if preferred_app_path:
|
||||
debug_dir = os.path.dirname(preferred_app_path)
|
||||
sibling = os.path.join(debug_dir, "cmux")
|
||||
if os.path.exists(sibling) and os.access(sibling, os.X_OK):
|
||||
return sibling
|
||||
|
||||
candidates = []
|
||||
home = os.path.expanduser("~")
|
||||
candidates.extend(glob.glob(os.path.join(
|
||||
home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"
|
||||
)))
|
||||
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
|
||||
candidates.extend(glob.glob("/private/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 not candidates:
|
||||
return ""
|
||||
|
||||
preferred_slug = _preferred_worktree_slug()
|
||||
if preferred_slug:
|
||||
preferred = [p for p in candidates if preferred_slug in p.lower()]
|
||||
if preferred:
|
||||
candidates = preferred
|
||||
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _wait_for_socket(socket_path: str, timeout: float = 10.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if os.path.exists(socket_path):
|
||||
return True
|
||||
try:
|
||||
sock = _raw_connect(socket_path, timeout=0.3)
|
||||
sock.close()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def _kill_cmux():
|
||||
subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True)
|
||||
def _kill_cmux(app_path: str = None):
|
||||
if app_path:
|
||||
exe = os.path.join(app_path, "Contents/MacOS/cmux DEV")
|
||||
subprocess.run(["pkill", "-f", exe], capture_output=True)
|
||||
else:
|
||||
subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True)
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
def _launch_cmux(app_path: str, socket_path: str, mode: str = None):
|
||||
def _launch_cmux(app_path: str, socket_path: str, mode: str = None, extra_env: dict = None):
|
||||
if os.path.exists(socket_path):
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
env_args = []
|
||||
if mode:
|
||||
env_args = ["--env", f"CMUX_SOCKET_MODE={mode}"]
|
||||
subprocess.Popen(["open", "-a", app_path] + env_args)
|
||||
launch_env = {
|
||||
"CMUX_SOCKET_PATH": socket_path,
|
||||
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
|
||||
}
|
||||
if extra_env:
|
||||
launch_env.update(extra_env)
|
||||
for key, value in launch_env.items():
|
||||
env_args.extend(["--env", f"{key}={value}"])
|
||||
subprocess.Popen(["open", "-na", app_path] + env_args)
|
||||
if not _wait_for_socket(socket_path):
|
||||
raise RuntimeError(f"Socket {socket_path} not created after launch")
|
||||
time.sleep(8)
|
||||
|
|
@ -249,8 +387,8 @@ fi
|
|||
f.write(hook_line)
|
||||
|
||||
# Kill existing cmux, launch in cmuxOnly mode (default)
|
||||
_kill_cmux()
|
||||
_launch_cmux(app_path, socket_path)
|
||||
_kill_cmux(app_path)
|
||||
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
|
||||
|
||||
# Wait for marker (the shell sources .zprofile on startup)
|
||||
for _ in range(40):
|
||||
|
|
@ -305,7 +443,7 @@ def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult:
|
|||
"""Verify CMUX_SOCKET_MODE=allowAll bypasses ancestry check."""
|
||||
result = TestResult("allowAll mode allows external")
|
||||
try:
|
||||
_kill_cmux()
|
||||
_kill_cmux(app_path)
|
||||
_launch_cmux(app_path, socket_path, mode="allowAll")
|
||||
|
||||
sock = _raw_connect(socket_path)
|
||||
|
|
@ -321,6 +459,178 @@ def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult:
|
|||
return result
|
||||
|
||||
|
||||
def test_password_mode_requires_auth(socket_path: str, app_path: str) -> TestResult:
|
||||
"""Verify password mode rejects unauthenticated commands."""
|
||||
result = TestResult("Password mode requires auth")
|
||||
password = f"cmux-pass-{os.getpid()}"
|
||||
try:
|
||||
_kill_cmux(app_path)
|
||||
_launch_cmux(
|
||||
app_path,
|
||||
socket_path,
|
||||
mode="password",
|
||||
extra_env={"CMUX_SOCKET_PASSWORD": password}
|
||||
)
|
||||
|
||||
sock = _raw_connect(socket_path)
|
||||
response = _raw_send(sock, "ping")
|
||||
sock.close()
|
||||
|
||||
if "Authentication required" in response:
|
||||
result.success("Unauthenticated command rejected in password mode")
|
||||
else:
|
||||
result.failure(f"Unexpected response without auth: {response!r}")
|
||||
except Exception as e:
|
||||
result.failure(f"{type(e).__name__}: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_password_mode_v1_auth_flow(socket_path: str, app_path: str) -> TestResult:
|
||||
"""Verify v1 auth command unlocks the connection only with correct password."""
|
||||
result = TestResult("Password mode v1 auth flow")
|
||||
password = f"cmux-pass-{os.getpid()}"
|
||||
try:
|
||||
_kill_cmux(app_path)
|
||||
_launch_cmux(
|
||||
app_path,
|
||||
socket_path,
|
||||
mode="password",
|
||||
extra_env={"CMUX_SOCKET_PASSWORD": password}
|
||||
)
|
||||
|
||||
sock = _raw_connect(socket_path)
|
||||
try:
|
||||
wrong = _raw_send(sock, "auth wrong-password")
|
||||
if "Invalid password" not in wrong:
|
||||
result.failure(f"Expected invalid password error, got: {wrong!r}")
|
||||
return result
|
||||
|
||||
ok = _raw_send(sock, f"auth {password}")
|
||||
if "OK: Authenticated" not in ok:
|
||||
result.failure(f"Expected auth success, got: {ok!r}")
|
||||
return result
|
||||
|
||||
pong = _raw_send(sock, "ping")
|
||||
if pong != "PONG":
|
||||
result.failure(f"Expected PONG after auth, got: {pong!r}")
|
||||
return result
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
result.success("v1 auth gate works")
|
||||
except Exception as e:
|
||||
result.failure(f"{type(e).__name__}: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_password_mode_v2_auth_flow(socket_path: str, app_path: str) -> TestResult:
|
||||
"""Verify v2 auth.login unlocks subsequent v2 requests."""
|
||||
result = TestResult("Password mode v2 auth flow")
|
||||
password = f"cmux-pass-{os.getpid()}"
|
||||
try:
|
||||
_kill_cmux(app_path)
|
||||
_launch_cmux(
|
||||
app_path,
|
||||
socket_path,
|
||||
mode="password",
|
||||
extra_env={"CMUX_SOCKET_PASSWORD": password}
|
||||
)
|
||||
|
||||
sock = _raw_connect(socket_path)
|
||||
try:
|
||||
unauth = _raw_send(sock, json.dumps({
|
||||
"id": "1",
|
||||
"method": "system.ping",
|
||||
"params": {}
|
||||
}))
|
||||
unauth_obj = json.loads(unauth)
|
||||
if unauth_obj.get("error", {}).get("code") != "auth_required":
|
||||
result.failure(f"Expected auth_required, got: {unauth!r}")
|
||||
return result
|
||||
|
||||
login = _raw_send(sock, json.dumps({
|
||||
"id": "2",
|
||||
"method": "auth.login",
|
||||
"params": {"password": password}
|
||||
}))
|
||||
login_obj = json.loads(login)
|
||||
if not login_obj.get("ok"):
|
||||
result.failure(f"Expected auth.login success, got: {login!r}")
|
||||
return result
|
||||
|
||||
pong = _raw_send(sock, json.dumps({
|
||||
"id": "3",
|
||||
"method": "system.ping",
|
||||
"params": {}
|
||||
}))
|
||||
pong_obj = json.loads(pong)
|
||||
pong_value = pong_obj.get("result", {}).get("pong")
|
||||
if pong_value is not True:
|
||||
result.failure(f"Expected pong=true after auth.login, got: {pong!r}")
|
||||
return result
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
result.success("v2 auth.login gate works")
|
||||
except Exception as e:
|
||||
result.failure(f"{type(e).__name__}: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_password_mode_cli_exit_code(socket_path: str, app_path: str) -> TestResult:
|
||||
"""Verify CLI exits non-zero on auth-required and succeeds with --password."""
|
||||
result = TestResult("Password mode CLI exit code")
|
||||
password = f"cmux-pass-{os.getpid()}"
|
||||
try:
|
||||
cli_path = _find_cli(preferred_app_path=app_path)
|
||||
if not cli_path:
|
||||
result.failure("Could not find cmux CLI binary")
|
||||
return result
|
||||
|
||||
_kill_cmux(app_path)
|
||||
_launch_cmux(
|
||||
app_path,
|
||||
socket_path,
|
||||
mode="password",
|
||||
extra_env={"CMUX_SOCKET_PASSWORD": password}
|
||||
)
|
||||
|
||||
no_auth = subprocess.run(
|
||||
[cli_path, "--socket", socket_path, "ping"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
combined = f"{no_auth.stdout}\n{no_auth.stderr}"
|
||||
if no_auth.returncode == 0:
|
||||
result.failure("CLI ping without password exited 0 in password mode")
|
||||
return result
|
||||
if "Authentication required" not in combined:
|
||||
result.failure(f"Unexpected unauthenticated CLI output: {combined!r}")
|
||||
return result
|
||||
|
||||
with_auth = subprocess.run(
|
||||
[cli_path, "--socket", socket_path, "--password", password, "ping"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if with_auth.returncode != 0:
|
||||
result.failure(
|
||||
f"CLI ping with password failed: exit={with_auth.returncode} "
|
||||
f"stdout={with_auth.stdout!r} stderr={with_auth.stderr!r}"
|
||||
)
|
||||
return result
|
||||
if "PONG" not in with_auth.stdout:
|
||||
result.failure(f"Expected PONG with password, got: {with_auth.stdout!r}")
|
||||
return result
|
||||
|
||||
result.success("CLI exits non-zero for auth_required and succeeds with --password")
|
||||
except Exception as e:
|
||||
result.failure(f"{type(e).__name__}: {e}")
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -337,7 +647,11 @@ def run_tests():
|
|||
return 1
|
||||
print(f"App: {app_path}")
|
||||
|
||||
socket_path = _find_socket_path()
|
||||
socket_path = f"/tmp/cmux-test-socket-access-{os.getpid()}.sock"
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"Socket: {socket_path}")
|
||||
print()
|
||||
|
||||
|
|
@ -356,9 +670,9 @@ def run_tests():
|
|||
print("-" * 50)
|
||||
|
||||
# Ensure cmux is running in cmuxOnly mode
|
||||
_kill_cmux()
|
||||
_kill_cmux(app_path)
|
||||
print(" Launching cmux in cmuxOnly mode...")
|
||||
_launch_cmux(app_path, socket_path)
|
||||
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
|
||||
|
||||
run_test(test_external_rejected, socket_path)
|
||||
run_test(test_connection_closed_after_reject, socket_path)
|
||||
|
|
@ -380,9 +694,19 @@ def run_tests():
|
|||
run_test(test_allowall_mode_works, socket_path, app_path)
|
||||
print()
|
||||
|
||||
# ── Phase 4: password mode auth gate ──
|
||||
print("Phase 4: password mode — auth required + login flow")
|
||||
print("-" * 50)
|
||||
|
||||
run_test(test_password_mode_requires_auth, socket_path, app_path)
|
||||
run_test(test_password_mode_v1_auth_flow, socket_path, app_path)
|
||||
run_test(test_password_mode_v2_auth_flow, socket_path, app_path)
|
||||
run_test(test_password_mode_cli_exit_code, socket_path, app_path)
|
||||
print()
|
||||
|
||||
# ── Cleanup: leave cmux in cmuxOnly mode ──
|
||||
_kill_cmux()
|
||||
_launch_cmux(app_path, socket_path)
|
||||
_kill_cmux(app_path)
|
||||
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
|
||||
|
||||
# ── Summary ──
|
||||
print("=" * 60)
|
||||
|
|
|
|||
106
tests/test_terminal_resize_portal_regressions.py
Normal file
106
tests/test_terminal_resize_portal_regressions.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for terminal tiny-pane resize/overflow fixes.
|
||||
|
||||
Guards the key invariants for issue #348:
|
||||
1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds.
|
||||
2) Surface sizing must prefer live bounds over stale pending values when available.
|
||||
"""
|
||||
|
||||
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] = []
|
||||
|
||||
portal_path = root / "Sources" / "TerminalWindowPortal.swift"
|
||||
portal_source = portal_path.read_text(encoding="utf-8")
|
||||
|
||||
if "hostView.layer?.masksToBounds = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView layer clipping")
|
||||
if "hostView.postsFrameChangedNotifications = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications")
|
||||
if "hostView.postsBoundsChangedNotifications = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications")
|
||||
|
||||
if "private func synchronizeLayoutHierarchy()" not in portal_source:
|
||||
failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()")
|
||||
if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source:
|
||||
failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()")
|
||||
if "hostedView.reconcileGeometryNow()" not in extract_block(
|
||||
portal_source,
|
||||
"func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)",
|
||||
):
|
||||
failures.append("bind() no longer pre-reconciles hosted geometry before attach")
|
||||
|
||||
sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)")
|
||||
for required in [
|
||||
"let hostBounds = hostView.bounds",
|
||||
"let clampedFrame = frameInHost.intersection(hostBounds)",
|
||||
"let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost",
|
||||
"scheduleDeferredFullSynchronizeAll()",
|
||||
"hostedView.reconcileGeometryNow()",
|
||||
"hostedView.refreshSurfaceNow()",
|
||||
]:
|
||||
if required not in sync_block:
|
||||
failures.append(f"terminal portal sync missing: {required}")
|
||||
|
||||
terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift"
|
||||
terminal_view_source = terminal_view_path.read_text(encoding="utf-8")
|
||||
|
||||
resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize")
|
||||
bounds_index = resolved_block.find("let currentBounds = bounds.size")
|
||||
pending_index = resolved_block.find("if let pending = pendingSurfaceSize")
|
||||
if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index:
|
||||
failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize")
|
||||
|
||||
update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)")
|
||||
if "let size = resolvedSurfaceSize(preferred: size)" not in update_block:
|
||||
failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()")
|
||||
|
||||
if failures:
|
||||
print("FAIL: terminal resize/portal regression guards failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: terminal resize/portal regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -918,6 +918,27 @@ class cmux:
|
|||
def activate_app(self) -> None:
|
||||
self._call("debug.app.activate")
|
||||
|
||||
def open_command_palette_rename_tab_input(self, window_id: Optional[str] = None) -> None:
|
||||
params: Dict[str, Any] = {}
|
||||
if window_id is not None:
|
||||
params["window_id"] = str(window_id)
|
||||
self._call("debug.command_palette.rename_tab.open", params)
|
||||
|
||||
def command_palette_results(self, window_id: str, limit: int = 20) -> dict:
|
||||
res = self._call(
|
||||
"debug.command_palette.results",
|
||||
{"window_id": str(window_id), "limit": int(limit)},
|
||||
) or {}
|
||||
return dict(res)
|
||||
|
||||
def command_palette_rename_select_all(self) -> bool:
|
||||
res = self._call("debug.command_palette.rename_input.select_all") or {}
|
||||
return bool(res.get("enabled"))
|
||||
|
||||
def set_command_palette_rename_select_all(self, enabled: bool) -> bool:
|
||||
res = self._call("debug.command_palette.rename_input.select_all", {"enabled": bool(enabled)}) or {}
|
||||
return bool(res.get("enabled"))
|
||||
|
||||
def is_terminal_focused(self, panel: Union[str, int]) -> bool:
|
||||
sid = self._resolve_surface_id(panel)
|
||||
res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {}
|
||||
|
|
|
|||
124
tests_v2/test_cli_new_workspace_command_queue.py
Normal file
124
tests_v2/test_cli_new_workspace_command_queue.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: `new-workspace --command` should execute without selecting the workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: list[str]) -> tuple[subprocess.CompletedProcess[str], float]:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
started = time.monotonic()
|
||||
proc = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
return proc, elapsed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
marker = Path(tempfile.gettempdir()) / f"cmux_new_workspace_command_{os.getpid()}.txt"
|
||||
created_ws_id: str | None = None
|
||||
|
||||
try:
|
||||
marker.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
try:
|
||||
baseline_ws_id = c.current_workspace()
|
||||
token = f"queued-{os.getpid()}-{int(time.time() * 1000)}"
|
||||
cmd_text = f"echo {token} > {marker}"
|
||||
|
||||
proc, elapsed = _run_cli(cli, ["new-workspace", "--command", cmd_text])
|
||||
combined = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
_must(proc.returncode == 0, f"CLI failed ({proc.returncode}): {combined}")
|
||||
_must(elapsed < 1.5, f"new-workspace --command should return quickly, took {elapsed:.2f}s")
|
||||
|
||||
output = (proc.stdout or "").strip()
|
||||
_must(output.startswith("OK "), f"Expected OK response, got: {output!r}")
|
||||
_must("Surface not ready" not in combined, f"Unexpected surface readiness error: {combined}")
|
||||
created_ws_id = output[3:].strip()
|
||||
_must(bool(created_ws_id), f"Missing workspace id in output: {output!r}")
|
||||
|
||||
# Creation with --command should not steal focus.
|
||||
_must(c.current_workspace() == baseline_ws_id, "new-workspace --command should preserve selected workspace")
|
||||
|
||||
observed = ""
|
||||
deadline = time.time() + 12.0
|
||||
while time.time() < deadline:
|
||||
if marker.exists():
|
||||
try:
|
||||
observed = marker.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
observed = ""
|
||||
if observed:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
_must(marker.exists(), f"Command marker file was not created: {marker}")
|
||||
_must(observed == token, f"Queued command did not execute as expected: expected={token!r} observed={observed!r}")
|
||||
_must(c.current_workspace() == baseline_ws_id, "Command execution should not switch selected workspace")
|
||||
finally:
|
||||
if created_ws_id:
|
||||
try:
|
||||
c.close_workspace(created_ws_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
marker.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print("PASS: new-workspace --command executes without opening the created workspace")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
91
tests_v2/test_cli_non_focus_commands_preserve_workspace.py
Normal file
91
tests_v2/test_cli_non_focus_commands_preserve_workspace.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: non-focus CLI commands should not switch the selected workspace."""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str]) -> str:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _current_workspace(c: cmux) -> str:
|
||||
payload = c._call("workspace.current") or {}
|
||||
ws_id = str(payload.get("workspace_id") or "")
|
||||
if not ws_id:
|
||||
raise cmuxError(f"workspace.current returned no workspace_id: {payload}")
|
||||
return ws_id
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
baseline_ws = _current_workspace(c)
|
||||
|
||||
created = _run_cli(cli, ["new-workspace"])
|
||||
_must(created.startswith("OK "), f"new-workspace expected OK response, got: {created}")
|
||||
created_ws = created.removeprefix("OK ").strip()
|
||||
_must(bool(created_ws), f"new-workspace returned no workspace id: {created}")
|
||||
_must(_current_workspace(c) == baseline_ws, "new-workspace should not switch selected workspace")
|
||||
|
||||
_run_cli(cli, ["new-surface", "--workspace", created_ws])
|
||||
_must(_current_workspace(c) == baseline_ws, "new-surface --workspace should not switch selected workspace")
|
||||
|
||||
_run_cli(cli, ["new-pane", "--workspace", created_ws, "--direction", "right"])
|
||||
_must(_current_workspace(c) == baseline_ws, "new-pane --workspace should not switch selected workspace")
|
||||
|
||||
_run_cli(cli, ["tab-action", "--workspace", created_ws, "--action", "new-terminal-right"])
|
||||
_must(_current_workspace(c) == baseline_ws, "tab-action new-terminal-right should not switch selected workspace")
|
||||
|
||||
c.close_workspace(created_ws)
|
||||
|
||||
print("PASS: non-focus CLI commands preserve selected workspace")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
158
tests_v2/test_command_palette_backspace_go_back.py
Normal file
158
tests_v2/test_command_palette_backspace_go_back.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: backspace on empty rename input returns to command list.
|
||||
|
||||
Coverage:
|
||||
- First backspace clears selected rename text.
|
||||
- Second backspace on empty rename input navigates back to command list mode.
|
||||
"""
|
||||
|
||||
import os
|
||||
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=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client, window_id):
|
||||
return client.command_palette_results(window_id, limit=20)
|
||||
|
||||
|
||||
def _rename_selection(client, window_id):
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _int_or(value, default):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return int(default)
|
||||
|
||||
|
||||
def _open_rename_input(client, window_id):
|
||||
client.activate_app()
|
||||
client.focus_window(window_id)
|
||||
time.sleep(0.1)
|
||||
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close before setup",
|
||||
)
|
||||
|
||||
client.open_command_palette_rename_tab_input(window_id=window_id)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="command palette failed to open",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "rename_input",
|
||||
message="command palette did not enter rename input mode",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
window_id = client.current_window()
|
||||
|
||||
original_select_all = client.command_palette_rename_select_all()
|
||||
|
||||
try:
|
||||
client.set_command_palette_rename_select_all(True)
|
||||
_open_rename_input(client, window_id)
|
||||
|
||||
_wait_until(
|
||||
lambda: bool(_rename_selection(client, window_id).get("focused")),
|
||||
message="rename input did not focus",
|
||||
)
|
||||
|
||||
selection = _rename_selection(client, window_id)
|
||||
text_length = _int_or(selection.get("text_length"), 0)
|
||||
selection_location = _int_or(selection.get("selection_location"), -1)
|
||||
selection_length = _int_or(selection.get("selection_length"), -1)
|
||||
if not (
|
||||
text_length > 0
|
||||
and selection_location in (-1, 0)
|
||||
and selection_length == text_length
|
||||
):
|
||||
raise cmuxError(
|
||||
"rename input was not select-all on open: "
|
||||
f"text_length={text_length} selection=({selection_location}, {selection_length})"
|
||||
)
|
||||
|
||||
client._call(
|
||||
"debug.command_palette.rename_input.delete_backward",
|
||||
{"window_id": window_id},
|
||||
)
|
||||
|
||||
first_backspace_cleared = False
|
||||
last_selection = {}
|
||||
for _ in range(40):
|
||||
last_selection = _rename_selection(client, window_id)
|
||||
if _int_or(last_selection.get("text_length"), -1) == 0:
|
||||
first_backspace_cleared = True
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not first_backspace_cleared:
|
||||
raise cmuxError(
|
||||
"first backspace did not clear rename input: "
|
||||
f"selection={last_selection} results={_palette_results(client, window_id)}"
|
||||
)
|
||||
after_first = _palette_results(client, window_id)
|
||||
if str(after_first.get("mode") or "") != "rename_input":
|
||||
raise cmuxError(f"palette exited rename mode too early after first backspace: {after_first}")
|
||||
|
||||
client._call(
|
||||
"debug.command_palette.rename_input.delete_backward",
|
||||
{"window_id": window_id},
|
||||
)
|
||||
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
|
||||
message="second backspace on empty input did not return to commands mode",
|
||||
)
|
||||
|
||||
if not _palette_visible(client, window_id):
|
||||
raise cmuxError("palette closed unexpectedly instead of navigating back to command list")
|
||||
|
||||
finally:
|
||||
try:
|
||||
client.set_command_palette_rename_select_all(original_select_all)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close during cleanup",
|
||||
)
|
||||
|
||||
print("PASS: backspace on empty rename input navigates back to command list")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
97
tests_v2/test_command_palette_focus.py
Normal file
97
tests_v2/test_command_palette_focus.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: opening the command palette must move focus away from terminal.
|
||||
|
||||
Why: if terminal remains first responder under the palette, typing goes into the shell
|
||||
instead of the palette search field.
|
||||
"""
|
||||
|
||||
import os
|
||||
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 _focused_surface_id(client: cmux) -> str:
|
||||
surfaces = client.list_surfaces()
|
||||
for _, sid, focused in surfaces:
|
||||
if focused:
|
||||
return sid
|
||||
raise cmuxError(f"No focused surface in list_surfaces: {surfaces}")
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(res.get("visible"))
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 3.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
token = "CMUX_PALETTE_FOCUS_PROBE_9412"
|
||||
restore_token = "CMUX_PALETTE_RESTORE_PROBE_7731"
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.new_workspace()
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
panel_id = _focused_surface_id(client)
|
||||
_wait_until(
|
||||
lambda: client.is_terminal_focused(panel_id),
|
||||
timeout_s=5.0,
|
||||
message=f"terminal never became focused for panel {panel_id}",
|
||||
)
|
||||
|
||||
pre_text = client.read_terminal_text(panel_id)
|
||||
|
||||
# Open palette via debug method and assert terminal focus drops.
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
timeout_s=3.0,
|
||||
message="command palette did not open",
|
||||
)
|
||||
|
||||
# Typing now should target palette input, not the terminal.
|
||||
client.simulate_type(token)
|
||||
time.sleep(0.15)
|
||||
post_text = client.read_terminal_text(panel_id)
|
||||
|
||||
if token in post_text and token not in pre_text:
|
||||
raise cmuxError("typed probe text leaked into terminal while palette is open")
|
||||
|
||||
# Close palette and ensure focus returns to previously-focused terminal.
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
timeout_s=3.0,
|
||||
message="command palette did not close",
|
||||
)
|
||||
|
||||
client.simulate_type(restore_token)
|
||||
time.sleep(0.15)
|
||||
restore_text = client.read_terminal_text(panel_id)
|
||||
if restore_token not in restore_text:
|
||||
raise cmuxError("terminal did not receive typing after closing command palette")
|
||||
|
||||
print("PASS: command palette steals and restores terminal focus")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
125
tests_v2/test_command_palette_focus_lock_workspace_spawn.py
Normal file
125
tests_v2/test_command_palette_focus_lock_workspace_spawn.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command palette focus must remain stable while a new workspace shell spawns.
|
||||
|
||||
Why: when a terminal steals first responder during workspace bootstrap, the command-palette
|
||||
search field can re-focus with full selection, so the next keystroke replaces the whole query.
|
||||
"""
|
||||
|
||||
import os
|
||||
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, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _palette_input_selection(client: cmux, window_id: str) -> dict:
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _close_palette_if_open(client: cmux, window_id: str) -> None:
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close",
|
||||
)
|
||||
|
||||
|
||||
def _assert_caret_at_end(selection: dict, context: str) -> None:
|
||||
if not selection.get("focused"):
|
||||
raise cmuxError(f"{context}: palette input is not focused")
|
||||
text_length = int(selection.get("text_length") or 0)
|
||||
selection_location = int(selection.get("selection_location") or 0)
|
||||
selection_length = int(selection.get("selection_length") or 0)
|
||||
if selection_location != text_length or selection_length != 0:
|
||||
raise cmuxError(
|
||||
f"{context}: expected caret-at-end, got location={selection_location}, "
|
||||
f"length={selection_length}, text_length={text_length}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
_close_palette_if_open(client, window_id)
|
||||
workspace_count_before = len(client.list_workspaces(window_id=window_id))
|
||||
|
||||
client.simulate_shortcut("cmd+shift+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+shift+p did not open command palette",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
|
||||
message="palette did not open in commands mode",
|
||||
)
|
||||
|
||||
selection = _palette_input_selection(client, window_id)
|
||||
_assert_caret_at_end(selection, "initial state")
|
||||
|
||||
client.new_workspace(window_id=window_id)
|
||||
_wait_until(
|
||||
lambda: len(client.list_workspaces(window_id=window_id)) >= workspace_count_before + 1,
|
||||
message="workspace.create did not add a new workspace",
|
||||
)
|
||||
|
||||
# Sample across shell bootstrap; focus and caret should stay stable.
|
||||
sample_deadline = time.time() + 2.0
|
||||
while time.time() < sample_deadline:
|
||||
selection = _palette_input_selection(client, window_id)
|
||||
_assert_caret_at_end(selection, "after workspace spawn")
|
||||
time.sleep(0.01)
|
||||
|
||||
client.simulate_type("focuslock")
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
|
||||
message="typing after workspace spawn switched palette out of commands mode",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: "focuslock" in str(_palette_results(client, window_id).get("query") or "").lower(),
|
||||
message="typing after workspace spawn did not append into command query",
|
||||
)
|
||||
|
||||
print("PASS: command palette keeps focus/caret during workspace shell spawn")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
133
tests_v2/test_command_palette_fuzzy_ranking.py
Normal file
133
tests_v2/test_command_palette_fuzzy_ranking.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command palette fuzzy ranking for rename commands.
|
||||
|
||||
Validates:
|
||||
- Typing `rename` is captured by the palette query.
|
||||
- The top-ranked command is a rename command.
|
||||
- Pressing Enter opens rename input (instead of running an unrelated command).
|
||||
"""
|
||||
|
||||
import os
|
||||
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")
|
||||
RENAME_COMMAND_IDS = {"palette.renameTab", "palette.renameWorkspace"}
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s=5.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _rename_input_selection(client: cmux, window_id: str) -> dict:
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 10) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
_set_palette_visible(client, window_id, True)
|
||||
|
||||
# Force command mode query regardless transient field-editor selection state.
|
||||
time.sleep(0.2)
|
||||
client.simulate_shortcut("cmd+a")
|
||||
client.simulate_type(">rename")
|
||||
_wait_until(
|
||||
lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="palette query did not update to 'rename'",
|
||||
)
|
||||
|
||||
payload = _palette_results(client, window_id, limit=12)
|
||||
rows = payload.get("results") or []
|
||||
if not rows:
|
||||
raise cmuxError(f"palette returned no results for rename query: {payload}")
|
||||
|
||||
top = rows[0] or {}
|
||||
top_id = str(top.get("command_id") or "")
|
||||
top_title = str(top.get("title") or "")
|
||||
if top_id not in RENAME_COMMAND_IDS:
|
||||
titles = [str(row.get("title") or "") for row in rows]
|
||||
raise cmuxError(
|
||||
f"unexpected top result for 'rename': id={top_id!r} title={top_title!r} results={titles}"
|
||||
)
|
||||
|
||||
client.simulate_shortcut("cmd+a")
|
||||
client.simulate_type(">retab")
|
||||
_wait_until(
|
||||
lambda: "retab" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="palette query did not update to 'retab'",
|
||||
)
|
||||
|
||||
retab_payload = _palette_results(client, window_id, limit=12)
|
||||
retab_rows = retab_payload.get("results") or []
|
||||
if not retab_rows:
|
||||
raise cmuxError(f"palette returned no results for retab query: {retab_payload}")
|
||||
top_retabs = [str(row.get("command_id") or "") for row in retab_rows[:3]]
|
||||
if "palette.renameTab" not in top_retabs:
|
||||
raise cmuxError(
|
||||
f"'retab' did not rank Rename Tab near top: top3={top_retabs} rows={retab_rows}"
|
||||
)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id)
|
||||
and bool(_rename_input_selection(client, window_id).get("focused")),
|
||||
message="Enter did not open rename input for top rename result",
|
||||
)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
print("PASS: command palette fuzzy ranking prioritizes rename commands")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
194
tests_v2/test_command_palette_modes.py
Normal file
194
tests_v2/test_command_palette_modes.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: VSCode-like command palette modes.
|
||||
|
||||
Validates:
|
||||
- Cmd+Shift+P opens commands mode (leading '>' semantics).
|
||||
- Cmd+P opens workspace/tab switcher mode.
|
||||
- Repeating Cmd+Shift+P or Cmd+P toggles visibility (open/close).
|
||||
- Switcher search can jump to another workspace by pressing Enter.
|
||||
"""
|
||||
|
||||
import os
|
||||
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, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _palette_input_selection(client: cmux, window_id: str) -> dict:
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _wait_for_palette_input_caret_at_end(
|
||||
client: cmux,
|
||||
window_id: str,
|
||||
expected_text_length: int,
|
||||
message: str,
|
||||
timeout_s: float = 1.2,
|
||||
) -> None:
|
||||
def _matches() -> bool:
|
||||
selection = _palette_input_selection(client, window_id)
|
||||
if not selection.get("focused"):
|
||||
return False
|
||||
text_length = int(selection.get("text_length") or 0)
|
||||
selection_location = int(selection.get("selection_location") or 0)
|
||||
selection_length = int(selection.get("selection_length") or 0)
|
||||
return (
|
||||
text_length == expected_text_length
|
||||
and selection_location == expected_text_length
|
||||
and selection_length == 0
|
||||
)
|
||||
|
||||
_wait_until(_matches, timeout_s=timeout_s, message=message)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
timeout_s=3.0,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
ws_a = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(ws_a)
|
||||
client.rename_workspace("alpha-workspace", workspace=ws_a)
|
||||
|
||||
ws_b = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(ws_b)
|
||||
client.rename_workspace("bravo-workspace", workspace=ws_b)
|
||||
|
||||
client.select_workspace(ws_a)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace() == ws_a,
|
||||
message="failed to select workspace alpha before switcher jump",
|
||||
)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
# Cmd+P: switcher mode.
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open command palette",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
time.sleep(0.2)
|
||||
client.simulate_type("bravo")
|
||||
_wait_until(
|
||||
lambda: "bravo" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="switcher query did not include bravo",
|
||||
)
|
||||
switched_rows = (_palette_results(client, window_id, limit=12).get("results") or [])
|
||||
if not switched_rows:
|
||||
raise cmuxError("switcher returned no rows for workspace query")
|
||||
top_id = str((switched_rows[0] or {}).get("command_id") or "")
|
||||
if not top_id.startswith("switcher."):
|
||||
raise cmuxError(f"expected switcher row on top for cmd+p query, got: {switched_rows[0]}")
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="palette did not close after selecting switcher row",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace() == ws_b,
|
||||
message="Enter on switcher result did not move to target workspace",
|
||||
)
|
||||
|
||||
# Cmd+Shift+P: commands mode.
|
||||
client.simulate_shortcut("cmd+shift+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+shift+p did not open command palette",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
|
||||
message="cmd+shift+p did not open commands mode",
|
||||
)
|
||||
_wait_for_palette_input_caret_at_end(
|
||||
client,
|
||||
window_id,
|
||||
expected_text_length=1,
|
||||
message="cmd+shift+p should prefill '>' with caret at end (not selected)",
|
||||
)
|
||||
|
||||
command_rows = (_palette_results(client, window_id, limit=8).get("results") or [])
|
||||
if not command_rows:
|
||||
raise cmuxError("commands mode returned no rows")
|
||||
top_command_id = str((command_rows[0] or {}).get("command_id") or "")
|
||||
if not top_command_id.startswith("palette."):
|
||||
raise cmuxError(f"expected command row in commands mode, got: {command_rows[0]}")
|
||||
|
||||
# Repeating either shortcut should toggle visibility.
|
||||
client.simulate_shortcut("cmd+shift+p")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="second cmd+shift+p did not close the command palette",
|
||||
)
|
||||
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id)
|
||||
and str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not reopen switcher mode after toggle-close",
|
||||
)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="second cmd+p did not close the command palette",
|
||||
)
|
||||
|
||||
print("PASS: command palette cmd+p/cmd+shift+p open correct modes and toggle on repeat")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
143
tests_v2/test_command_palette_navigation_keys.py
Normal file
143
tests_v2/test_command_palette_navigation_keys.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command palette list navigation keys.
|
||||
|
||||
Validates:
|
||||
- Down: ArrowDown, Ctrl+N, Ctrl+J
|
||||
- Up: ArrowUp, Ctrl+P, Ctrl+K
|
||||
"""
|
||||
|
||||
import os
|
||||
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 = 4.0,
|
||||
interval_s: float = 0.05,
|
||||
message: str = "timeout",
|
||||
) -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(res.get("visible"))
|
||||
|
||||
|
||||
def _palette_selected_index(client: cmux, window_id: str) -> int:
|
||||
res = client._call("debug.command_palette.selection", {"window_id": window_id}) or {}
|
||||
return int(res.get("selected_index") or 0)
|
||||
|
||||
|
||||
def _has_focused_surface(client: cmux) -> bool:
|
||||
try:
|
||||
return any(bool(row[2]) for row in client.list_surfaces())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_palette_with_query(client: cmux, window_id: str, query: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
_set_palette_visible(client, window_id, True)
|
||||
client.simulate_type(query)
|
||||
_wait_until(
|
||||
lambda: _palette_selected_index(client, window_id) == 0,
|
||||
message="palette selected index did not reset to zero",
|
||||
)
|
||||
|
||||
|
||||
def _assert_move(client: cmux, window_id: str, combo: str, start_index: int, expected_index: int) -> None:
|
||||
_open_palette_with_query(client, window_id, "new")
|
||||
for _ in range(start_index):
|
||||
client.simulate_shortcut("down")
|
||||
_wait_until(
|
||||
lambda: _palette_selected_index(client, window_id) == start_index,
|
||||
message=f"failed to seed start index {start_index}",
|
||||
)
|
||||
|
||||
client.simulate_shortcut(combo)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id)
|
||||
and _palette_selected_index(client, window_id) == expected_index,
|
||||
message=f"{combo} did not move selection from {start_index} to {expected_index}",
|
||||
)
|
||||
|
||||
|
||||
def _assert_can_navigate_past_ten_results(client: cmux, window_id: str) -> None:
|
||||
_open_palette_with_query(client, window_id, "")
|
||||
|
||||
for _ in range(12):
|
||||
client.simulate_shortcut("down")
|
||||
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id)
|
||||
and _palette_selected_index(client, window_id) >= 10,
|
||||
message="selection did not move past index 9 (results may be capped)",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
client.new_workspace()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
# Isolate this test to one window so stale palettes in other windows
|
||||
# cannot steal navigation notifications.
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
_wait_until(
|
||||
lambda: _has_focused_surface(client),
|
||||
timeout_s=5.0,
|
||||
message="no focused surface available for command palette context",
|
||||
)
|
||||
|
||||
for combo in ("down", "ctrl+n", "ctrl+j"):
|
||||
_assert_move(client, window_id, combo, start_index=0, expected_index=1)
|
||||
|
||||
for combo in ("up", "ctrl+p", "ctrl+k"):
|
||||
_assert_move(client, window_id, combo, start_index=1, expected_index=0)
|
||||
|
||||
_assert_can_navigate_past_ten_results(client, window_id)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
print("PASS: command palette navigation keys and uncapped result navigation")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
106
tests_v2/test_command_palette_rename_enter.py
Normal file
106
tests_v2/test_command_palette_rename_enter.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette rename flow responds to Enter.
|
||||
|
||||
Coverage:
|
||||
- Enter in rename input applies the new tab name and closes the palette.
|
||||
"""
|
||||
|
||||
import os
|
||||
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=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _rename_input_selection(client, window_id):
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _focused_pane_id(client):
|
||||
panes = client.list_panes()
|
||||
focused = [row for row in panes if bool(row[3])]
|
||||
if not focused:
|
||||
raise cmuxError(f"no focused pane: {panes}")
|
||||
return str(focused[0][1])
|
||||
|
||||
|
||||
def _selected_surface_title(client, pane_id):
|
||||
rows = client.list_pane_surfaces(pane_id)
|
||||
selected = [row for row in rows if bool(row[3])]
|
||||
if not selected:
|
||||
raise cmuxError(f"no selected surface in pane {pane_id}: {rows}")
|
||||
return str(selected[0][2])
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
pane_id = _focused_pane_id(client)
|
||||
rename_to = f"rename-enter-{int(time.time())}"
|
||||
|
||||
client.open_command_palette_rename_tab_input(window_id=window_id)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="command palette did not open",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: bool(_rename_input_selection(client, window_id).get("focused")),
|
||||
message="rename input did not focus",
|
||||
)
|
||||
|
||||
client.simulate_type(rename_to)
|
||||
time.sleep(0.1)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="Enter did not apply rename and close palette",
|
||||
)
|
||||
|
||||
new_title = _selected_surface_title(client, pane_id)
|
||||
if new_title != rename_to:
|
||||
raise cmuxError(f"rename not applied: expected '{rename_to}', got '{new_title}'")
|
||||
|
||||
print("PASS: command-palette rename flow accepts Enter in input")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
185
tests_v2/test_command_palette_rename_select_all.py
Normal file
185
tests_v2/test_command_palette_rename_select_all.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette rename input keeps select-all on interaction.
|
||||
|
||||
Coverage:
|
||||
- With select-all setting enabled, rename input selects all existing text
|
||||
immediately and stays selected after interaction.
|
||||
"""
|
||||
|
||||
import os
|
||||
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=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _rename_input_selection(client, window_id):
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _rename_select_all_setting(client):
|
||||
payload = client._call("debug.command_palette.rename_input.select_all", {}) or {}
|
||||
return bool(payload.get("enabled"))
|
||||
|
||||
|
||||
def _set_rename_select_all_setting(client, enabled):
|
||||
payload = client._call(
|
||||
"debug.command_palette.rename_input.select_all",
|
||||
{"enabled": bool(enabled)},
|
||||
) or {}
|
||||
return bool(payload.get("enabled"))
|
||||
|
||||
|
||||
def _wait_for_rename_selection(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all,
|
||||
message,
|
||||
timeout_s=0.6,
|
||||
):
|
||||
def _matches():
|
||||
selection = _rename_input_selection(client, window_id)
|
||||
if not selection.get("focused"):
|
||||
return False
|
||||
text_length = int(selection.get("text_length") or 0)
|
||||
selection_location = int(selection.get("selection_location") or 0)
|
||||
selection_length = int(selection.get("selection_length") or 0)
|
||||
if expect_select_all:
|
||||
return text_length > 0 and selection_location == 0 and selection_length == text_length
|
||||
return selection_location == text_length and selection_length == 0
|
||||
|
||||
_wait_until(_matches, timeout_s=timeout_s, message=message)
|
||||
|
||||
|
||||
def _exercise_rename_selection_setting(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all,
|
||||
cycles,
|
||||
label,
|
||||
):
|
||||
for cycle in range(cycles):
|
||||
_open_rename_tab_input(client, window_id)
|
||||
_wait_for_rename_selection(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all=expect_select_all,
|
||||
timeout_s=0.4,
|
||||
message=(
|
||||
f"{label}: rename input not ready with expected selection "
|
||||
f"on open (cycle {cycle + 1}/{cycles})"
|
||||
),
|
||||
)
|
||||
client._call("debug.command_palette.rename_input.interact", {"window_id": window_id})
|
||||
_wait_for_rename_selection(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all=expect_select_all,
|
||||
timeout_s=0.6,
|
||||
message=(
|
||||
f"{label}: rename input selection changed after interaction "
|
||||
f"(cycle {cycle + 1}/{cycles})"
|
||||
),
|
||||
)
|
||||
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message=f"{label}: command palette failed to close (cycle {cycle + 1}/{cycles})",
|
||||
)
|
||||
|
||||
|
||||
def _open_rename_tab_input(client, window_id):
|
||||
client.activate_app()
|
||||
client.focus_window(window_id)
|
||||
time.sleep(0.1)
|
||||
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close before setup",
|
||||
)
|
||||
|
||||
client.open_command_palette_rename_tab_input(window_id=window_id)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="command palette failed to open rename-tab input",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
original_select_all = _rename_select_all_setting(client)
|
||||
|
||||
workspace_id = client.new_workspace()
|
||||
client.select_workspace(workspace_id)
|
||||
client.rename_workspace("SeedName", workspace_id)
|
||||
time.sleep(0.25)
|
||||
window_id = client.current_window()
|
||||
|
||||
try:
|
||||
stress_cycles = 8
|
||||
|
||||
# ON: immediate select-all and interaction-preserved select-all.
|
||||
_set_rename_select_all_setting(client, True)
|
||||
_exercise_rename_selection_setting(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all=True,
|
||||
cycles=stress_cycles,
|
||||
label="select-all enabled",
|
||||
)
|
||||
|
||||
# OFF: immediate caret-at-end and interaction-preserved caret-at-end.
|
||||
_set_rename_select_all_setting(client, False)
|
||||
_exercise_rename_selection_setting(
|
||||
client,
|
||||
window_id,
|
||||
expect_select_all=False,
|
||||
cycles=stress_cycles,
|
||||
label="select-all disabled",
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
_set_rename_select_all_setting(client, original_select_all)
|
||||
except Exception:
|
||||
pass
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close during cleanup",
|
||||
)
|
||||
|
||||
print("PASS: command-palette rename input obeys select-all setting (on/off)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
122
tests_v2/test_command_palette_search_action_sync.py
Normal file
122
tests_v2/test_command_palette_search_action_sync.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette search updates rows and executed action in sync.
|
||||
|
||||
Why: if query replacement doesn't fully refresh the result list, the top row text
|
||||
can lag behind the action executed on Enter.
|
||||
"""
|
||||
|
||||
import os
|
||||
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=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _set_palette_visible(client, window_id, visible):
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"command palette did not become visible={visible}",
|
||||
)
|
||||
|
||||
|
||||
def _palette_results(client, window_id, limit=10):
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _palette_input_selection(client, window_id):
|
||||
# Shared field-editor probe used by other command palette regressions.
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
_set_palette_visible(client, window_id, True)
|
||||
_wait_until(
|
||||
lambda: bool(_palette_input_selection(client, window_id).get("focused")),
|
||||
message="palette search input did not focus",
|
||||
)
|
||||
|
||||
client.simulate_shortcut("cmd+a")
|
||||
client.simulate_type(">open")
|
||||
_wait_until(
|
||||
lambda: "open" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="palette query did not become 'open'",
|
||||
)
|
||||
|
||||
before = _palette_results(client, window_id, limit=8)
|
||||
before_rows = before.get("results") or []
|
||||
if not before_rows:
|
||||
raise cmuxError(f"no results for 'open': {before}")
|
||||
if str(before_rows[0].get("command_id") or "") != "palette.terminalOpenDirectory":
|
||||
raise cmuxError(f"unexpected top command for 'open': {before_rows[0]}")
|
||||
|
||||
client.simulate_shortcut("cmd+a")
|
||||
client.simulate_type(">rename")
|
||||
_wait_until(
|
||||
lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="palette query did not become 'rename' after replacement",
|
||||
)
|
||||
after = _palette_results(client, window_id, limit=8)
|
||||
after_rows = after.get("results") or []
|
||||
if not after_rows:
|
||||
raise cmuxError(f"no results for 'rename' after replacement: {after}")
|
||||
top_after = str(after_rows[0].get("command_id") or "")
|
||||
if top_after not in {"palette.renameWorkspace", "palette.renameTab"}:
|
||||
raise cmuxError(f"top result did not update to rename command after replacement: {after_rows[0]}")
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: bool(_palette_input_selection(client, window_id).get("focused")),
|
||||
message="Enter did not trigger renamed top command input",
|
||||
)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
print("PASS: command-palette search replacement keeps row text/action in sync")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
121
tests_v2/test_command_palette_search_typing_stability.py
Normal file
121
tests_v2/test_command_palette_search_typing_stability.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette search typing should not reset selection.
|
||||
|
||||
Why: if focus-lock logic repeatedly re-focuses the text field, typing behaves
|
||||
like Cmd+A is being spammed and each character replaces the previous query.
|
||||
"""
|
||||
|
||||
import os
|
||||
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=4.0, interval_s=0.04, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client, window_id):
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_input_selection(client, window_id):
|
||||
# Uses the shared field-editor probe; works for search and rename modes.
|
||||
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
|
||||
|
||||
|
||||
def _wait_for_input_state(client, window_id, expected_text_length, message, timeout_s=0.8):
|
||||
def _matches():
|
||||
selection = _palette_input_selection(client, window_id)
|
||||
if not selection.get("focused"):
|
||||
return False
|
||||
text_length = int(selection.get("text_length") or 0)
|
||||
selection_location = int(selection.get("selection_location") or 0)
|
||||
selection_length = int(selection.get("selection_length") or 0)
|
||||
return (
|
||||
text_length == expected_text_length
|
||||
and selection_location == expected_text_length
|
||||
and selection_length == 0
|
||||
)
|
||||
|
||||
_wait_until(_matches, timeout_s=timeout_s, message=message)
|
||||
|
||||
|
||||
def _close_palette_if_open(client, window_id):
|
||||
if _palette_visible(client, window_id):
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="command palette failed to close",
|
||||
)
|
||||
|
||||
|
||||
def _open_palette(client, window_id):
|
||||
_close_palette_if_open(client, window_id)
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="command palette failed to open",
|
||||
)
|
||||
_wait_for_input_state(
|
||||
client,
|
||||
window_id,
|
||||
expected_text_length=0,
|
||||
message="search input did not focus with empty query",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
|
||||
# Keep a single active window for deterministic first-responder behavior.
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
probe = "typingstability"
|
||||
cycles = 4
|
||||
for cycle in range(cycles):
|
||||
_open_palette(client, window_id)
|
||||
for idx, ch in enumerate(probe, start=1):
|
||||
client.simulate_type(ch)
|
||||
_wait_for_input_state(
|
||||
client,
|
||||
window_id,
|
||||
expected_text_length=idx,
|
||||
timeout_s=0.7,
|
||||
message=(
|
||||
f"search typing did not accumulate at cycle {cycle + 1}/{cycles}, "
|
||||
f"char {idx}/{len(probe)}"
|
||||
),
|
||||
)
|
||||
_close_palette_if_open(client, window_id)
|
||||
|
||||
print("PASS: command-palette search typing accumulates text without select-all churn")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
119
tests_v2/test_command_palette_shortcut_hint_sync.py
Normal file
119
tests_v2/test_command_palette_shortcut_hint_sync.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command-palette shortcut hints stay in sync with editable shortcuts.
|
||||
|
||||
Validates:
|
||||
- New Window / Close Window / Rename Tab commands are present in command mode.
|
||||
- Their displayed shortcut hints reflect the current KeyboardShortcutSettings values.
|
||||
"""
|
||||
|
||||
import os
|
||||
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=4.0, interval_s=0.05, message="timeout"):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"command palette did not become visible={visible}",
|
||||
)
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit=12) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _open_palette_and_rows(client: cmux, window_id: str, limit: int = 80) -> list:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
_set_palette_visible(client, window_id, True)
|
||||
payload = _palette_results(client, window_id, limit=limit)
|
||||
rows = payload.get("results") or []
|
||||
if not rows:
|
||||
raise cmuxError(f"command palette returned no rows: {payload}")
|
||||
return rows
|
||||
|
||||
|
||||
def _assert_shortcut_hint(rows: list, command_id: str, expected_hint: str) -> None:
|
||||
row = next((row for row in rows if str((row or {}).get("command_id") or "") == command_id), None)
|
||||
if row is None:
|
||||
raise cmuxError(f"missing command palette row for {command_id!r}; rows={rows}")
|
||||
shortcut_hint = str((row or {}).get("shortcut_hint") or "")
|
||||
if shortcut_hint != expected_hint:
|
||||
raise cmuxError(
|
||||
f"unexpected shortcut hint for {command_id}: expected {expected_hint!r}, got {shortcut_hint!r} row={row}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
shortcut_names = ["new_window", "close_window", "rename_tab"]
|
||||
try:
|
||||
rows = _open_palette_and_rows(client, window_id)
|
||||
_assert_shortcut_hint(rows, "palette.newWindow", "⇧⌘N")
|
||||
_assert_shortcut_hint(rows, "palette.closeWindow", "⌃⌘W")
|
||||
_assert_shortcut_hint(rows, "palette.renameTab", "⌘R")
|
||||
|
||||
client.set_shortcut("new_window", "cmd+opt+n")
|
||||
client.set_shortcut("close_window", "cmd+opt+w")
|
||||
client.set_shortcut("rename_tab", "cmd+ctrl+r")
|
||||
|
||||
rows = _open_palette_and_rows(client, window_id)
|
||||
_assert_shortcut_hint(rows, "palette.newWindow", "⌥⌘N")
|
||||
_assert_shortcut_hint(rows, "palette.closeWindow", "⌥⌘W")
|
||||
_assert_shortcut_hint(rows, "palette.renameTab", "⌃⌘R")
|
||||
finally:
|
||||
for name in shortcut_names:
|
||||
try:
|
||||
client.set_shortcut(name, "clear")
|
||||
except cmuxError:
|
||||
pass
|
||||
_set_palette_visible(client, window_id, False)
|
||||
|
||||
print("PASS: command-palette shortcut hints track editable shortcuts for new/close/rename window-tab actions")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
128
tests_v2/test_command_palette_switcher_all_windows.py
Normal file
128
tests_v2/test_command_palette_switcher_all_windows.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: cmd+p switcher should include workspaces from every window.
|
||||
|
||||
Why: switcher rows were sourced from the current window's TabManager only, so
|
||||
Cmd+P could not jump to workspaces/tabs owned by other windows.
|
||||
"""
|
||||
|
||||
import os
|
||||
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 = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility in {window_id} did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_a = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_a:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_a)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_b = client.new_window()
|
||||
time.sleep(0.25)
|
||||
|
||||
token_suffix = f"{int(time.time() * 1000)}"
|
||||
token_a = f"cmdp-window-a-{token_suffix}"
|
||||
token_b = f"cmdp-window-b-{token_suffix}"
|
||||
|
||||
workspace_a = client.new_workspace(window_id=window_a)
|
||||
client.rename_workspace(token_a, workspace=workspace_a)
|
||||
|
||||
workspace_b = client.new_workspace(window_id=window_b)
|
||||
client.rename_workspace(token_b, workspace=workspace_b)
|
||||
time.sleep(0.25)
|
||||
|
||||
client.focus_window(window_a)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
_set_palette_visible(client, window_a, False)
|
||||
_set_palette_visible(client, window_b, False)
|
||||
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_a),
|
||||
message="cmd+p did not open palette in window A",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_a).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode in window A",
|
||||
)
|
||||
|
||||
client.simulate_type(token_b)
|
||||
_wait_until(
|
||||
lambda: token_b in str(_palette_results(client, window_a).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update with window B token",
|
||||
)
|
||||
|
||||
result_rows = (_palette_results(client, window_a, limit=64).get("results") or [])
|
||||
target_workspace_command = f"switcher.workspace.{workspace_b.lower()}"
|
||||
if not any(str((row or {}).get("command_id") or "") == target_workspace_command for row in result_rows):
|
||||
raise cmuxError(
|
||||
f"cmd+p switcher in window A did not include workspace from window B "
|
||||
f"(expected {target_workspace_command}); rows={result_rows[:8]}"
|
||||
)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_a),
|
||||
message="palette did not close after selecting cross-window switcher row",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace().lower() == workspace_b.lower(),
|
||||
message="Enter on cross-window switcher row did not move to window B workspace",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: client.current_window().lower() == window_b.lower(),
|
||||
message="Enter on cross-window switcher row did not focus window B",
|
||||
)
|
||||
|
||||
print("PASS: cmd+p switcher includes and navigates to workspaces from other windows")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: cmd+p switcher surface selection across workspaces must focus that surface.
|
||||
|
||||
Why: switching workspaces with an explicit target surface could be overridden by stale
|
||||
per-workspace remembered focus, leaving the destination workspace selected but the wrong
|
||||
surface focused.
|
||||
"""
|
||||
|
||||
import os
|
||||
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 = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_switcher(client: cmux, window_id: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open switcher",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
|
||||
def _rename_surface(client: cmux, surface_id: str, title: str) -> None:
|
||||
client._call(
|
||||
"surface.action",
|
||||
{
|
||||
"surface_id": surface_id,
|
||||
"action": "rename",
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _current_surface_id(client: cmux, workspace_id: str) -> str:
|
||||
payload = client._call("surface.current", {"workspace_id": workspace_id}) or {}
|
||||
return str(payload.get("surface_id") or "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
ws_a = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(ws_a)
|
||||
client.rename_workspace("source-workspace", workspace=ws_a)
|
||||
|
||||
ws_b = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(ws_b)
|
||||
client.rename_workspace("target-workspace", workspace=ws_b)
|
||||
time.sleep(0.2)
|
||||
|
||||
right_surface_id = client.new_split("right")
|
||||
time.sleep(0.2)
|
||||
|
||||
payload = client._call("surface.list", {"workspace_id": ws_b}) or {}
|
||||
rows = payload.get("surfaces") or []
|
||||
if len(rows) < 2:
|
||||
raise cmuxError(f"expected at least two surfaces after split: {payload}")
|
||||
|
||||
left_surface_id = ""
|
||||
for row in rows:
|
||||
sid = str(row.get("id") or "")
|
||||
if sid and sid != right_surface_id:
|
||||
left_surface_id = sid
|
||||
break
|
||||
if not left_surface_id:
|
||||
raise cmuxError(f"failed to resolve left surface id: {payload}")
|
||||
|
||||
token = f"cmdp-crossws-{int(time.time() * 1000)}"
|
||||
_rename_surface(client, right_surface_id, token)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_surface(left_surface_id)
|
||||
_wait_until(
|
||||
lambda: _current_surface_id(client, ws_b).lower() == left_surface_id.lower(),
|
||||
message="failed to prime remembered focus on non-target surface",
|
||||
)
|
||||
|
||||
client.select_workspace(ws_a)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace() == ws_a,
|
||||
message="failed to return to source workspace before cmd+p navigation",
|
||||
)
|
||||
|
||||
_open_switcher(client, window_id)
|
||||
client.simulate_type(token)
|
||||
_wait_until(
|
||||
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update to target token",
|
||||
)
|
||||
|
||||
target_command_id = f"switcher.surface.{ws_b.lower()}.{right_surface_id.lower()}"
|
||||
_wait_until(
|
||||
lambda: str(((_palette_results(client, window_id, limit=24).get("results") or [{}])[0] or {}).get("command_id") or "") == target_command_id,
|
||||
message="target surface row did not become top switcher result",
|
||||
)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="palette did not close after selecting cross-workspace surface row",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: client.current_workspace() == ws_b,
|
||||
message="Enter on switcher surface row did not move to target workspace",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: _current_surface_id(client, ws_b).lower() == right_surface_id.lower(),
|
||||
message="Enter on cross-workspace switcher surface row did not focus target surface",
|
||||
)
|
||||
|
||||
client.close_workspace(ws_b)
|
||||
client.close_workspace(ws_a)
|
||||
|
||||
print("PASS: cmd+p switcher focuses selected surface after cross-workspace navigation")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
160
tests_v2/test_command_palette_switcher_renamed_surface.py
Normal file
160
tests_v2/test_command_palette_switcher_renamed_surface.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: cmd+p switcher should search and navigate to renamed surfaces.
|
||||
"""
|
||||
|
||||
import os
|
||||
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 = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_switcher(client: cmux, window_id: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open switcher",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
|
||||
def _rename_surface(client: cmux, surface_id: str, title: str) -> None:
|
||||
client._call(
|
||||
"surface.action",
|
||||
{
|
||||
"surface_id": surface_id,
|
||||
"action": "rename",
|
||||
"title": title,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _current_surface_id(client: cmux, workspace_id: str) -> str:
|
||||
payload = client._call("surface.current", {"workspace_id": workspace_id}) or {}
|
||||
return str(payload.get("surface_id") or "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
right_surface_id = client.new_split("right")
|
||||
time.sleep(0.2)
|
||||
|
||||
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
rows = payload.get("surfaces") or []
|
||||
if len(rows) < 2:
|
||||
raise cmuxError(f"expected at least two surfaces after split: {payload}")
|
||||
|
||||
left_surface_id = ""
|
||||
for row in rows:
|
||||
sid = str(row.get("id") or "")
|
||||
if sid and sid != right_surface_id:
|
||||
left_surface_id = sid
|
||||
break
|
||||
if not left_surface_id:
|
||||
raise cmuxError(f"failed to resolve left surface id: {payload}")
|
||||
|
||||
token = f"renamed-surface-{int(time.time() * 1000)}"
|
||||
_rename_surface(client, right_surface_id, token)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_surface(left_surface_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
_open_switcher(client, window_id)
|
||||
client.simulate_type(token)
|
||||
_wait_until(
|
||||
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update to renamed surface token",
|
||||
)
|
||||
|
||||
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
|
||||
if not result_rows:
|
||||
raise cmuxError("switcher returned no rows for renamed surface query")
|
||||
|
||||
top_row = result_rows[0] or {}
|
||||
top_id = str(top_row.get("command_id") or "")
|
||||
top_title = str(top_row.get("title") or "")
|
||||
if not top_id.startswith("switcher.surface."):
|
||||
raise cmuxError(
|
||||
f"expected renamed surface row on top, got top={top_id!r} rows={result_rows}"
|
||||
)
|
||||
if top_title != token:
|
||||
raise cmuxError(
|
||||
f"expected top surface row title to match renamed title {token!r}, got {top_title!r}"
|
||||
)
|
||||
|
||||
client.simulate_shortcut("enter")
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, window_id),
|
||||
message="palette did not close after selecting renamed surface row",
|
||||
)
|
||||
|
||||
_wait_until(
|
||||
lambda: _current_surface_id(client, workspace_id).lower() == right_surface_id.lower(),
|
||||
message="Enter on renamed surface switcher row did not focus target surface",
|
||||
)
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
|
||||
print("PASS: cmd+p switcher searches and navigates renamed surfaces")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
155
tests_v2/test_command_palette_switcher_surface_precedence.py
Normal file
155
tests_v2/test_command_palette_switcher_surface_precedence.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: switcher should prioritize matching surfaces over workspace rows.
|
||||
|
||||
Why: workspace rows used to index metadata from all surfaces, so a path-token query
|
||||
could rank the workspace row above the actual surface row (because of stable rank
|
||||
tie-breaks), making Enter jump to workspace instead of the intended surface.
|
||||
"""
|
||||
|
||||
import os
|
||||
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 = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_switcher(client: cmux, window_id: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open switcher",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
client.rename_workspace("workspace-no-token", workspace=workspace_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
right_surface_id = client.new_split("right")
|
||||
time.sleep(0.2)
|
||||
|
||||
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
rows = payload.get("surfaces") or []
|
||||
if len(rows) < 2:
|
||||
raise cmuxError(f"expected at least two surfaces after split: {payload}")
|
||||
|
||||
left_surface_id = ""
|
||||
for row in rows:
|
||||
sid = str(row.get("id") or "")
|
||||
if sid and sid != right_surface_id:
|
||||
left_surface_id = sid
|
||||
break
|
||||
if not left_surface_id:
|
||||
raise cmuxError(f"failed to resolve left surface id: {payload}")
|
||||
|
||||
token = f"cmdp-switcher-target-{int(time.time() * 1000)}"
|
||||
target_dir = f"/tmp/{token}"
|
||||
|
||||
client.send_surface(left_surface_id, "cd /tmp\n")
|
||||
client.send_surface(
|
||||
right_surface_id,
|
||||
f"mkdir -p {target_dir} && cd {target_dir}\n",
|
||||
)
|
||||
client.focus_surface(left_surface_id)
|
||||
time.sleep(0.8)
|
||||
|
||||
_open_switcher(client, window_id)
|
||||
client.simulate_type(token)
|
||||
_wait_until(
|
||||
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update to target token",
|
||||
)
|
||||
|
||||
def _has_surface_match() -> bool:
|
||||
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
|
||||
return any(str((row or {}).get("command_id") or "").startswith("switcher.surface.") for row in result_rows)
|
||||
|
||||
_wait_until(
|
||||
_has_surface_match,
|
||||
timeout_s=8.0,
|
||||
message="switcher results never produced a matching surface row for token query",
|
||||
)
|
||||
|
||||
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
|
||||
if not result_rows:
|
||||
raise cmuxError("switcher returned no rows for token query")
|
||||
|
||||
top_id = str((result_rows[0] or {}).get("command_id") or "")
|
||||
if not top_id.startswith("switcher.surface."):
|
||||
raise cmuxError(f"expected a surface row on top for token query, got top={top_id!r} rows={result_rows}")
|
||||
|
||||
workspace_matches = [
|
||||
str((row or {}).get("command_id") or "")
|
||||
for row in result_rows
|
||||
if str((row or {}).get("command_id") or "").startswith("switcher.workspace.")
|
||||
]
|
||||
if workspace_matches:
|
||||
raise cmuxError(
|
||||
f"workspace row should not match a non-focused surface path token; workspace matches={workspace_matches} rows={result_rows}"
|
||||
)
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.close_workspace(workspace_id)
|
||||
|
||||
print("PASS: switcher ranks matching surface rows ahead of workspace rows for path-token queries")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
127
tests_v2/test_command_palette_switcher_type_labels.py
Normal file
127
tests_v2/test_command_palette_switcher_type_labels.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: cmd+p switcher rows expose right-side type labels.
|
||||
|
||||
Expected trailing labels:
|
||||
- switcher.workspace.* => Workspace
|
||||
- switcher.surface.* => Surface
|
||||
"""
|
||||
|
||||
import os
|
||||
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 = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
message=f"palette visibility did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _open_switcher(client: cmux, window_id: str) -> None:
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id),
|
||||
message="cmd+p did not open switcher",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
|
||||
message="cmd+p did not open switcher mode",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_id = client.current_window()
|
||||
for row in client.list_windows():
|
||||
other_id = str(row.get("id") or "")
|
||||
if other_id and other_id != window_id:
|
||||
client.close_window(other_id)
|
||||
time.sleep(0.2)
|
||||
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
workspace_id = client.new_workspace(window_id=window_id)
|
||||
client.select_workspace(workspace_id)
|
||||
token = f"switchertype{int(time.time() * 1000)}"
|
||||
client.rename_workspace(token, workspace=workspace_id)
|
||||
_ = client.new_split("right")
|
||||
time.sleep(0.3)
|
||||
|
||||
_open_switcher(client, window_id)
|
||||
client.simulate_type(token)
|
||||
_wait_until(
|
||||
lambda: token in str(_palette_results(client, window_id, limit=60).get("query") or "").strip().lower(),
|
||||
message="switcher query did not update to workspace token",
|
||||
)
|
||||
|
||||
rows = (_palette_results(client, window_id, limit=60).get("results") or [])
|
||||
if not rows:
|
||||
raise cmuxError("switcher returned no rows for token query")
|
||||
|
||||
workspace_rows = [
|
||||
row for row in rows
|
||||
if str((row or {}).get("command_id") or "").startswith("switcher.workspace.")
|
||||
]
|
||||
surface_rows = [
|
||||
row for row in rows
|
||||
if str((row or {}).get("command_id") or "").startswith("switcher.surface.")
|
||||
]
|
||||
|
||||
if not workspace_rows:
|
||||
raise cmuxError(f"expected workspace rows for switcher query: rows={rows}")
|
||||
if not surface_rows:
|
||||
raise cmuxError(f"expected surface rows for switcher query: rows={rows}")
|
||||
|
||||
bad_workspace = [row for row in workspace_rows if str((row or {}).get("trailing_label") or "") != "Workspace"]
|
||||
if bad_workspace:
|
||||
raise cmuxError(f"workspace rows missing 'Workspace' trailing label: {bad_workspace}")
|
||||
|
||||
bad_surface = [row for row in surface_rows if str((row or {}).get("trailing_label") or "") != "Surface"]
|
||||
if bad_surface:
|
||||
raise cmuxError(f"surface rows missing 'Surface' trailing label: {bad_surface}")
|
||||
|
||||
_set_palette_visible(client, window_id, False)
|
||||
client.close_workspace(workspace_id)
|
||||
|
||||
print("PASS: cmd+p switcher rows report Workspace/Surface trailing labels")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
219
tests_v2/test_command_palette_window_scope.py
Normal file
219
tests_v2/test_command_palette_window_scope.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: command palette should open only in the active window.
|
||||
|
||||
Why: if command-palette toggle is broadcast to all windows, inactive windows can
|
||||
end up with an open palette that steals focus once they become key.
|
||||
"""
|
||||
|
||||
import os
|
||||
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, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _palette_visible(client: cmux, window_id: str) -> bool:
|
||||
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
|
||||
return bool(res.get("visible"))
|
||||
|
||||
|
||||
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
|
||||
return client.command_palette_results(window_id=window_id, limit=limit)
|
||||
|
||||
|
||||
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
|
||||
if _palette_visible(client, window_id) == visible:
|
||||
return
|
||||
client._call("debug.command_palette.toggle", {"window_id": window_id})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, window_id) == visible,
|
||||
timeout_s=3.0,
|
||||
message=f"palette in {window_id} did not become {visible}",
|
||||
)
|
||||
|
||||
|
||||
def _focus_window(client: cmux, window_id: str) -> None:
|
||||
client.focus_window(window_id)
|
||||
client.activate_app()
|
||||
_wait_until(
|
||||
lambda: client.current_window().lower() == window_id.lower(),
|
||||
timeout_s=3.0,
|
||||
message=f"failed to focus window {window_id}",
|
||||
)
|
||||
time.sleep(0.15)
|
||||
|
||||
|
||||
def _assert_shortcut_window_scoped(client: cmux, shortcut: str, w1: str, w2: str) -> None:
|
||||
_set_palette_visible(client, w1, False)
|
||||
_set_palette_visible(client, w2, False)
|
||||
|
||||
_focus_window(client, w1)
|
||||
client.simulate_shortcut(shortcut)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message=f"{shortcut} did not open palette in window1",
|
||||
)
|
||||
if _palette_visible(client, w2):
|
||||
raise cmuxError(f"{shortcut} in window1 incorrectly opened palette in window2")
|
||||
|
||||
_focus_window(client, w2)
|
||||
client.simulate_shortcut(shortcut)
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message=f"{shortcut} did not open palette in window2",
|
||||
)
|
||||
if not _palette_visible(client, w1):
|
||||
raise cmuxError(
|
||||
f"{shortcut} in window2 incorrectly toggled window1 palette off "
|
||||
"(cross-window routing regression)"
|
||||
)
|
||||
|
||||
client.simulate_shortcut(shortcut)
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message=f"second {shortcut} did not close palette in window2",
|
||||
)
|
||||
if not _palette_visible(client, w1):
|
||||
raise cmuxError(
|
||||
f"second {shortcut} in window2 incorrectly changed window1 palette visibility"
|
||||
)
|
||||
|
||||
_focus_window(client, w1)
|
||||
client.simulate_shortcut(shortcut)
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message=f"second {shortcut} did not close palette in window1",
|
||||
)
|
||||
|
||||
|
||||
def _assert_cross_window_typing_after_mixed_shortcuts(client: cmux, w1: str, w2: str) -> None:
|
||||
_set_palette_visible(client, w1, False)
|
||||
_set_palette_visible(client, w2, False)
|
||||
|
||||
_focus_window(client, w1)
|
||||
client.simulate_shortcut("cmd+shift+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message="cmd+shift+p did not open palette in window1",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, w1).get("mode") or "") == "commands",
|
||||
timeout_s=3.0,
|
||||
message="window1 palette did not enter commands mode",
|
||||
)
|
||||
window1_query_before = str(_palette_results(client, w1).get("query") or "")
|
||||
|
||||
_focus_window(client, w2)
|
||||
client.simulate_shortcut("cmd+p")
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message="cmd+p did not open palette in window2",
|
||||
)
|
||||
_wait_until(
|
||||
lambda: str(_palette_results(client, w2).get("mode") or "") == "switcher",
|
||||
timeout_s=3.0,
|
||||
message="window2 palette did not enter switcher mode",
|
||||
)
|
||||
|
||||
typed = ""
|
||||
for ch in "crosswindow":
|
||||
typed += ch
|
||||
client.simulate_type(ch)
|
||||
_wait_until(
|
||||
lambda expected=typed: str(_palette_results(client, w2).get("query") or "").lower() == expected,
|
||||
timeout_s=1.8,
|
||||
message=(
|
||||
"typing into window2 palette did not accumulate query text "
|
||||
f"(expected {typed!r})"
|
||||
),
|
||||
)
|
||||
|
||||
window1_query_now = str(_palette_results(client, w1).get("query") or "")
|
||||
if window1_query_now != window1_query_before:
|
||||
raise cmuxError(
|
||||
"typing in window2 changed window1 command-palette query "
|
||||
f"(before={window1_query_before!r}, now={window1_query_now!r})"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
w1 = client.current_window()
|
||||
w2 = client.new_window()
|
||||
time.sleep(0.25)
|
||||
|
||||
_ = client.new_workspace(window_id=w1)
|
||||
_ = client.new_workspace(window_id=w2)
|
||||
time.sleep(0.25)
|
||||
_set_palette_visible(client, w1, False)
|
||||
_set_palette_visible(client, w2, False)
|
||||
|
||||
# Open palette in window1 and verify window2 remains untouched.
|
||||
client._call("debug.command_palette.toggle", {"window_id": w1})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message="window1 command palette did not open",
|
||||
)
|
||||
if _palette_visible(client, w2):
|
||||
raise cmuxError("window2 palette became visible when toggling window1")
|
||||
|
||||
# Closing window1 palette should not affect window2.
|
||||
client._call("debug.command_palette.toggle", {"window_id": w1})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, w1),
|
||||
timeout_s=3.0,
|
||||
message="window1 command palette did not close",
|
||||
)
|
||||
|
||||
# Mirror the same check in the other direction.
|
||||
client._call("debug.command_palette.toggle", {"window_id": w2})
|
||||
_wait_until(
|
||||
lambda: _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message="window2 command palette did not open",
|
||||
)
|
||||
if _palette_visible(client, w1):
|
||||
raise cmuxError("window1 palette became visible when toggling window2")
|
||||
client._call("debug.command_palette.toggle", {"window_id": w2})
|
||||
_wait_until(
|
||||
lambda: not _palette_visible(client, w2),
|
||||
timeout_s=3.0,
|
||||
message="window2 command palette did not close",
|
||||
)
|
||||
|
||||
# Reproduce keyboard-shortcut window-scoping path:
|
||||
# opening from window2 must not jump back and toggle window1.
|
||||
_assert_shortcut_window_scoped(client, "cmd+shift+p", w1, w2)
|
||||
_assert_shortcut_window_scoped(client, "cmd+p", w1, w2)
|
||||
_assert_cross_window_typing_after_mixed_shortcuts(client, w1, w2)
|
||||
|
||||
print("PASS: command palette is scoped to active window")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -9,6 +9,7 @@ This test checks for:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s
|
|||
return violations
|
||||
|
||||
|
||||
def check_command_palette_caret_tint(repo_root: Path) -> List[str]:
|
||||
"""Ensure command palette text inputs keep a white caret tint."""
|
||||
content_view = repo_root / "Sources" / "ContentView.swift"
|
||||
if not content_view.exists():
|
||||
return [f"Missing expected file: {content_view}"]
|
||||
|
||||
try:
|
||||
content = content_view.read_text()
|
||||
except Exception as e:
|
||||
return [f"Could not read {content_view}: {e}"]
|
||||
|
||||
checks = [
|
||||
(
|
||||
"search input",
|
||||
r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteSearchFocused\)",
|
||||
),
|
||||
(
|
||||
"rename input",
|
||||
r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteRenameFocused\)",
|
||||
),
|
||||
]
|
||||
|
||||
violations: List[str] = []
|
||||
for label, pattern in checks:
|
||||
match = re.search(pattern, content, flags=re.DOTALL)
|
||||
if not match:
|
||||
violations.append(
|
||||
f"Could not locate command palette {label} TextField block in Sources/ContentView.swift"
|
||||
)
|
||||
continue
|
||||
|
||||
body = match.group("body")
|
||||
if ".tint(.white)" not in body:
|
||||
violations.append(
|
||||
f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift"
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the lint checks."""
|
||||
repo_root = get_repo_root()
|
||||
|
|
@ -102,15 +145,18 @@ def main():
|
|||
print(f"Checking {len(swift_files)} Swift files for performance issues...")
|
||||
|
||||
# Check for auto-updating Text styles
|
||||
violations = check_autoupdating_text_styles(swift_files)
|
||||
style_violations = check_autoupdating_text_styles(swift_files)
|
||||
tint_violations = check_command_palette_caret_tint(repo_root)
|
||||
has_failures = False
|
||||
|
||||
if violations:
|
||||
if style_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Auto-updating Text styles found")
|
||||
print("=" * 60)
|
||||
print("These patterns cause continuous SwiftUI view updates and high CPU usage:")
|
||||
print()
|
||||
|
||||
for file_path, line_num, line in violations:
|
||||
for file_path, line_num, line in style_violations:
|
||||
rel_path = file_path.relative_to(repo_root)
|
||||
print(f" {rel_path}:{line_num}")
|
||||
print(f" {line}")
|
||||
|
|
@ -120,9 +166,23 @@ def main():
|
|||
print(" Instead of: Text(date, style: .time)")
|
||||
print(" Use: Text(date.formatted(date: .omitted, time: .shortened))")
|
||||
print()
|
||||
|
||||
if tint_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Command palette caret tint drifted")
|
||||
print("=" * 60)
|
||||
print("The command palette search and rename text fields must keep a white caret:")
|
||||
print()
|
||||
for message in tint_violations:
|
||||
print(f" {message}")
|
||||
print()
|
||||
print("FIX: Set command palette TextField tint modifiers to `.white`.")
|
||||
print()
|
||||
|
||||
if has_failures:
|
||||
return 1
|
||||
|
||||
print("✅ No auto-updating Text style patterns found")
|
||||
print("✅ No linted SwiftUI pattern regressions found")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
129
tests_v2/test_rename_tab_cli_parity.py
Normal file
129
tests_v2/test_rename_tab_cli_parity.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: explicit `rename-tab` CLI command parity with tab.action rename."""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> str:
|
||||
merged_env = dict(os.environ)
|
||||
merged_env.pop("CMUX_WORKSPACE_ID", None)
|
||||
merged_env.pop("CMUX_SURFACE_ID", None)
|
||||
merged_env.pop("CMUX_TAB_ID", None)
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=merged_env)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = c._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
for row in payload.get("surfaces") or []:
|
||||
if str(row.get("id") or "") == surface_id:
|
||||
return str(row.get("title") or "")
|
||||
raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
stamp = int(time.time() * 1000)
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
caps = c.capabilities() or {}
|
||||
methods = set(caps.get("methods") or [])
|
||||
_must("tab.action" in methods, f"Missing tab.action in capabilities: {sorted(methods)[:40]}")
|
||||
|
||||
created = c._call("workspace.create") or {}
|
||||
ws_id = str(created.get("workspace_id") or "")
|
||||
_must(bool(ws_id), f"workspace.create returned no workspace_id: {created}")
|
||||
|
||||
c._call("workspace.select", {"workspace_id": ws_id})
|
||||
current = c._call("surface.current", {"workspace_id": ws_id}) or {}
|
||||
surface_id = str(current.get("surface_id") or "")
|
||||
_must(bool(surface_id), f"surface.current returned no surface_id: {current}")
|
||||
|
||||
socket_title = f"socket rename {stamp}"
|
||||
c._call(
|
||||
"tab.action",
|
||||
{
|
||||
"workspace_id": ws_id,
|
||||
"surface_id": surface_id,
|
||||
"action": "rename",
|
||||
"title": socket_title,
|
||||
},
|
||||
)
|
||||
_must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title")
|
||||
|
||||
cli_title = f"cli rename {stamp}"
|
||||
_run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title])
|
||||
_must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title")
|
||||
|
||||
env_title = f"env rename {stamp}"
|
||||
_run_cli(
|
||||
cli,
|
||||
["rename-tab", env_title],
|
||||
env={
|
||||
"CMUX_WORKSPACE_ID": ws_id,
|
||||
"CMUX_TAB_ID": surface_id,
|
||||
},
|
||||
)
|
||||
_must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title")
|
||||
|
||||
invalid = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_TAB_ID"}},
|
||||
)
|
||||
invalid_output = f"{invalid.stdout}\n{invalid.stderr}"
|
||||
_must(invalid.returncode != 0, "Expected rename-tab without title to fail")
|
||||
_must("rename-tab requires a title" in invalid_output, f"Unexpected rename-tab error: {invalid_output!r}")
|
||||
|
||||
c.close_workspace(ws_id)
|
||||
|
||||
print("PASS: rename-tab CLI parity works with explicit and env-derived targets")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -7,7 +7,7 @@ import subprocess
|
|||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
|
@ -39,11 +39,13 @@ def _find_cli_binary() -> str:
|
|||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: List[str]) -> str:
|
||||
def _run_cli(cli: str, args: List[str], env_overrides: Optional[Dict[str, str]] = None) -> str:
|
||||
env = dict(os.environ)
|
||||
# Keep this test deterministic when running from inside another cmux shell.
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
if env_overrides:
|
||||
env.update(env_overrides)
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
if proc.returncode != 0:
|
||||
|
|
@ -93,6 +95,17 @@ def main() -> int:
|
|||
"cmux rename-window without --workspace should target current workspace",
|
||||
)
|
||||
|
||||
env_title = f"tmux env {stamp}"
|
||||
_run_cli(
|
||||
cli,
|
||||
["rename-workspace", env_title],
|
||||
env_overrides={"CMUX_WORKSPACE_ID": ws_id},
|
||||
)
|
||||
_must(
|
||||
_workspace_title(c, ws_id) == env_title,
|
||||
"cmux rename-workspace should default to CMUX_WORKSPACE_ID",
|
||||
)
|
||||
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
|
|
|
|||
107
tests_v2/test_shortcut_window_scope.py
Normal file
107
tests_v2/test_shortcut_window_scope.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: app shortcuts must apply to the focused window only.
|
||||
|
||||
Covers:
|
||||
- Cmd+B (toggle sidebar) should only affect the active window.
|
||||
- Cmd+T (new terminal tab/surface) should only affect the active window.
|
||||
"""
|
||||
|
||||
import os
|
||||
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 = 4.0, interval_s: float = 0.05, message: str = "timeout") -> None:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise cmuxError(message)
|
||||
|
||||
|
||||
def _sidebar_visible(client: cmux, window_id: str) -> bool:
|
||||
payload = client._call("debug.sidebar.visible", {"window_id": window_id}) or {}
|
||||
return bool(payload.get("visible"))
|
||||
|
||||
|
||||
def _surface_count(client: cmux, workspace_id: str) -> int:
|
||||
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
return len(payload.get("surfaces") or [])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
window_a = client.current_window()
|
||||
window_b = client.new_window()
|
||||
time.sleep(0.25)
|
||||
|
||||
workspace_a = client.new_workspace(window_id=window_a)
|
||||
workspace_b = client.new_workspace(window_id=window_b)
|
||||
time.sleep(0.25)
|
||||
|
||||
client.focus_window(window_a)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
a_before = _sidebar_visible(client, window_a)
|
||||
b_before = _sidebar_visible(client, window_b)
|
||||
|
||||
client.simulate_shortcut("cmd+b")
|
||||
_wait_until(
|
||||
lambda: _sidebar_visible(client, window_a) != a_before,
|
||||
message="Cmd+B did not toggle sidebar in active window A",
|
||||
)
|
||||
a_after = _sidebar_visible(client, window_a)
|
||||
b_after = _sidebar_visible(client, window_b)
|
||||
if b_after != b_before:
|
||||
raise cmuxError("Cmd+B in window A incorrectly toggled sidebar in window B")
|
||||
|
||||
client.focus_window(window_b)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
client.simulate_shortcut("cmd+b")
|
||||
_wait_until(
|
||||
lambda: _sidebar_visible(client, window_b) != b_after,
|
||||
message="Cmd+B did not toggle sidebar in active window B",
|
||||
)
|
||||
if _sidebar_visible(client, window_a) != a_after:
|
||||
raise cmuxError("Cmd+B in window B incorrectly toggled sidebar in window A")
|
||||
|
||||
client.focus_window(window_a)
|
||||
client.activate_app()
|
||||
time.sleep(0.2)
|
||||
client.select_workspace(workspace_a)
|
||||
time.sleep(0.1)
|
||||
|
||||
count_a_before = _surface_count(client, workspace_a)
|
||||
count_b_before = _surface_count(client, workspace_b)
|
||||
|
||||
client.simulate_shortcut("cmd+t")
|
||||
_wait_until(
|
||||
lambda: _surface_count(client, workspace_a) == count_a_before + 1,
|
||||
message="Cmd+T did not create a new surface in active window A",
|
||||
)
|
||||
|
||||
count_b_after = _surface_count(client, workspace_b)
|
||||
if count_b_after != count_b_before:
|
||||
raise cmuxError("Cmd+T in window A incorrectly created a surface in window B")
|
||||
|
||||
print("PASS: window-scoped shortcuts stay in the active window (Cmd+B, Cmd+T)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
211
tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py
Normal file
211
tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fuzz regression: rapid Cmd+D / Ctrl+D churn must not shift the outer bonsplit container frame.
|
||||
|
||||
This targets the user-reported visual shift/flash while spamming split + close.
|
||||
We treat any drift in x/y/width/height of the outer container frame as a failure.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
import os
|
||||
import random
|
||||
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")
|
||||
FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_FUZZ_SEED", "424242"))
|
||||
FUZZ_STEPS = int(os.environ.get("CMUX_SPLIT_FUZZ_STEPS", "1400"))
|
||||
SAMPLES_PER_STEP = int(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLES", "4"))
|
||||
SAMPLE_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLE_INTERVAL_S", "0.0015"))
|
||||
ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_FUZZ_ACTION_JITTER_MAX_S", "0.0035"))
|
||||
BURST_MAX = int(os.environ.get("CMUX_SPLIT_FUZZ_BURST_MAX", "3"))
|
||||
MAX_PANES = int(os.environ.get("CMUX_SPLIT_FUZZ_MAX_PANES", "10"))
|
||||
EPSILON = float(os.environ.get("CMUX_SPLIT_FUZZ_EPSILON", "0.0"))
|
||||
TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_FUZZ_TRACE_TAIL", "40"))
|
||||
ASSERT_NO_UNDERFLOW = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_UNDERFLOW", "0") == "1"
|
||||
ASSERT_NO_EMPTY_PANEL = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_EMPTY_PANEL", "0") == "1"
|
||||
|
||||
|
||||
def _pane_count(layout_payload: dict) -> int:
|
||||
layout = layout_payload.get("layout") or {}
|
||||
panes = layout.get("panes") or []
|
||||
return len(panes)
|
||||
|
||||
|
||||
def _largest_split_frame(layout_payload: dict) -> dict:
|
||||
selected = layout_payload.get("selectedPanels") or []
|
||||
best = None
|
||||
best_area = -1.0
|
||||
|
||||
for row in selected:
|
||||
for split in row.get("splitViews") or []:
|
||||
frame = split.get("frame")
|
||||
if not frame:
|
||||
continue
|
||||
|
||||
try:
|
||||
x = float(frame.get("x", 0.0))
|
||||
y = float(frame.get("y", 0.0))
|
||||
width = float(frame.get("width", 0.0))
|
||||
height = float(frame.get("height", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if width <= 0.0 or height <= 0.0:
|
||||
continue
|
||||
|
||||
area = width * height
|
||||
if area > best_area:
|
||||
best_area = area
|
||||
best = {"x": x, "y": y, "width": width, "height": height}
|
||||
|
||||
if best is None:
|
||||
raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}")
|
||||
return best
|
||||
|
||||
|
||||
def _container_frame(layout_payload: dict) -> dict:
|
||||
container = (layout_payload.get("layout") or {}).get("containerFrame")
|
||||
if container:
|
||||
try:
|
||||
return {
|
||||
"x": float(container.get("x", 0.0)),
|
||||
"y": float(container.get("y", 0.0)),
|
||||
"width": float(container.get("width", 0.0)),
|
||||
"height": float(container.get("height", 0.0)),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Back-compat fallback for older payloads that don't expose containerFrame.
|
||||
return _largest_split_frame(layout_payload)
|
||||
|
||||
|
||||
def _assert_same_frame(
|
||||
current: dict,
|
||||
baseline: dict,
|
||||
*,
|
||||
step: int,
|
||||
sample: int,
|
||||
action: str,
|
||||
seed: int,
|
||||
action_index: int,
|
||||
trace: list[str],
|
||||
) -> None:
|
||||
deltas = {
|
||||
key: abs(float(current[key]) - float(baseline[key]))
|
||||
for key in ("x", "y", "width", "height")
|
||||
}
|
||||
shifted = {k: v for k, v in deltas.items() if v > EPSILON}
|
||||
if shifted:
|
||||
raise cmuxError(
|
||||
"Outer split container shifted during fuzz churn "
|
||||
f"(step={step}, sample={sample}, action={action}, action_index={action_index}, seed={seed}, "
|
||||
f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON})"
|
||||
f"; recent_actions={trace}"
|
||||
)
|
||||
|
||||
|
||||
def _warm_start_split(c: cmux) -> dict:
|
||||
# Ensure we have at least one split so the container frame exists in layout_debug.
|
||||
c.simulate_shortcut("cmd+d")
|
||||
deadline = time.time() + 2.0
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
payload = c.layout_debug()
|
||||
last = payload
|
||||
if _pane_count(payload) >= 2:
|
||||
return payload
|
||||
time.sleep(0.02)
|
||||
raise cmuxError(f"Timed out waiting for first split to appear: {last}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rng = random.Random(FUZZ_SEED)
|
||||
recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL))
|
||||
total_actions = 0
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
ws = c.new_workspace()
|
||||
c.select_workspace(ws)
|
||||
c.activate_app()
|
||||
time.sleep(0.2)
|
||||
|
||||
c.reset_bonsplit_underflow_count()
|
||||
c.reset_empty_panel_count()
|
||||
|
||||
initial = _warm_start_split(c)
|
||||
baseline = _container_frame(initial)
|
||||
if _pane_count(initial) < 2:
|
||||
raise cmuxError("Expected at least 2 panes after warm start split")
|
||||
|
||||
for step in range(1, FUZZ_STEPS + 1):
|
||||
burst = rng.randint(1, max(1, BURST_MAX))
|
||||
|
||||
for burst_index in range(1, burst + 1):
|
||||
before = c.layout_debug()
|
||||
pane_count = _pane_count(before)
|
||||
|
||||
if pane_count <= 2:
|
||||
action = "cmd+d"
|
||||
elif pane_count >= MAX_PANES:
|
||||
action = "ctrl+d"
|
||||
else:
|
||||
# Bias toward split to keep churn dense while still frequently collapsing via ctrl+d.
|
||||
action = "cmd+d" if rng.random() < 0.60 else "ctrl+d"
|
||||
|
||||
if action == "cmd+d":
|
||||
c.simulate_shortcut("cmd+d")
|
||||
else:
|
||||
# Ctrl+D equivalent sent directly to the focused terminal surface.
|
||||
c.send_ctrl_d()
|
||||
|
||||
total_actions += 1
|
||||
recent_actions.append(
|
||||
f"step={step}/burst={burst_index}/{burst} panes_before={pane_count} action={action}"
|
||||
)
|
||||
|
||||
# Random micro-jitter to emulate uneven key-repeat timing while keeping churn fast.
|
||||
if ACTION_JITTER_MAX_S > 0:
|
||||
time.sleep(rng.uniform(0.0, ACTION_JITTER_MAX_S))
|
||||
|
||||
# Sample repeatedly after each burst to catch transient shifts.
|
||||
for sample in range(0, SAMPLES_PER_STEP + 1):
|
||||
payload = c.layout_debug()
|
||||
current = _container_frame(payload)
|
||||
_assert_same_frame(
|
||||
current,
|
||||
baseline,
|
||||
step=step,
|
||||
sample=sample,
|
||||
action="burst",
|
||||
seed=FUZZ_SEED,
|
||||
action_index=total_actions,
|
||||
trace=list(recent_actions),
|
||||
)
|
||||
if SAMPLE_INTERVAL_S > 0:
|
||||
time.sleep(rng.uniform(0.0, SAMPLE_INTERVAL_S))
|
||||
|
||||
underflows = c.bonsplit_underflow_count()
|
||||
if ASSERT_NO_UNDERFLOW and underflows != 0:
|
||||
raise cmuxError(f"bonsplit arranged-subview underflow observed during fuzz run: {underflows}")
|
||||
|
||||
flashes = c.empty_panel_count()
|
||||
if ASSERT_NO_EMPTY_PANEL and flashes != 0:
|
||||
raise cmuxError(f"EmptyPanelView appeared during fuzz run (count={flashes})")
|
||||
|
||||
print(
|
||||
"PASS: cmd+d/ctrl+d fuzz geometry invariant "
|
||||
f"(seed={FUZZ_SEED}, steps={FUZZ_STEPS}, samples={SAMPLES_PER_STEP}, burst_max={BURST_MAX}, "
|
||||
f"actions={total_actions}, epsilon={EPSILON}, underflows={underflows}, empty_panel={flashes})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
487
tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py
Normal file
487
tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Focused fuzz regression for rapid Cmd+D / Ctrl+D churn in a strict 1<->2 pane loop.
|
||||
|
||||
Intent:
|
||||
- Keep topology limited to one pane or two left/right panes only.
|
||||
- Run across multiple fresh workspaces.
|
||||
- Sample layout as fast as the debug socket allows during transitions/holds.
|
||||
- Fail immediately if outer container x/y/width/height drifts at any sampled frame.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
import os
|
||||
import random
|
||||
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")
|
||||
FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_2PANE_SEED", "20260223"))
|
||||
WORKSPACES = int(os.environ.get("CMUX_SPLIT_2PANE_WORKSPACES", "3"))
|
||||
CYCLES_PER_WORKSPACE = int(os.environ.get("CMUX_SPLIT_2PANE_CYCLES", "220"))
|
||||
TRANSITION_TIMEOUT_S = float(os.environ.get("CMUX_SPLIT_2PANE_TIMEOUT_S", "2.0"))
|
||||
HOLD_MIN_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MIN_S", "0.003"))
|
||||
HOLD_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MAX_S", "0.018"))
|
||||
PRE_ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_ACTION_JITTER_MAX_S", "0.002"))
|
||||
EPSILON = float(os.environ.get("CMUX_SPLIT_2PANE_EPSILON", "0.0"))
|
||||
TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_2PANE_TRACE_TAIL", "64"))
|
||||
LAYOUT_POLL_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_POLL_SLEEP_S", "0.0008"))
|
||||
LAYOUT_TIMEOUT_RETRIES = int(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRIES", "4"))
|
||||
LAYOUT_TIMEOUT_RETRY_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRY_SLEEP_S", "0.0015"))
|
||||
MAX_LAYOUT_TIMEOUTS = int(os.environ.get("CMUX_SPLIT_2PANE_MAX_LAYOUT_TIMEOUTS", "80"))
|
||||
CTRL_D_RETRY_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_RETRY_INTERVAL_S", "0.18"))
|
||||
CTRL_D_MAX_EXTRA = int(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_MAX_EXTRA", "6"))
|
||||
|
||||
|
||||
def _pane_count(layout_payload: dict) -> int:
|
||||
layout = layout_payload.get("layout") or {}
|
||||
return len(layout.get("panes") or [])
|
||||
|
||||
|
||||
def _largest_split_frame(layout_payload: dict) -> dict:
|
||||
selected = layout_payload.get("selectedPanels") or []
|
||||
best = None
|
||||
best_area = -1.0
|
||||
for row in selected:
|
||||
for split in row.get("splitViews") or []:
|
||||
frame = split.get("frame")
|
||||
if not frame:
|
||||
continue
|
||||
try:
|
||||
x = float(frame.get("x", 0.0))
|
||||
y = float(frame.get("y", 0.0))
|
||||
width = float(frame.get("width", 0.0))
|
||||
height = float(frame.get("height", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if width <= 0.0 or height <= 0.0:
|
||||
continue
|
||||
area = width * height
|
||||
if area > best_area:
|
||||
best_area = area
|
||||
best = {"x": x, "y": y, "width": width, "height": height}
|
||||
if best is None:
|
||||
raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}")
|
||||
return best
|
||||
|
||||
|
||||
def _container_frame(layout_payload: dict) -> dict:
|
||||
container = (layout_payload.get("layout") or {}).get("containerFrame")
|
||||
if container:
|
||||
try:
|
||||
return {
|
||||
"x": float(container.get("x", 0.0)),
|
||||
"y": float(container.get("y", 0.0)),
|
||||
"width": float(container.get("width", 0.0)),
|
||||
"height": float(container.get("height", 0.0)),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return _largest_split_frame(layout_payload)
|
||||
|
||||
|
||||
def _pane_frames_sorted_x(layout_payload: dict) -> list[dict]:
|
||||
layout = layout_payload.get("layout") or {}
|
||||
panes = layout.get("panes") or []
|
||||
frames: list[dict] = []
|
||||
for pane in panes:
|
||||
frame = pane.get("frame") or {}
|
||||
try:
|
||||
frames.append(
|
||||
{
|
||||
"pane_id": str(pane.get("paneId") or ""),
|
||||
"x": float(frame.get("x", 0.0)),
|
||||
"y": float(frame.get("y", 0.0)),
|
||||
"width": float(frame.get("width", 0.0)),
|
||||
"height": float(frame.get("height", 0.0)),
|
||||
}
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return sorted(frames, key=lambda p: (p["x"], p["y"]))
|
||||
|
||||
|
||||
def _assert_same_frame(
|
||||
*,
|
||||
current: dict,
|
||||
baseline: dict,
|
||||
workspace_index: int,
|
||||
cycle: int,
|
||||
phase: str,
|
||||
sample: int,
|
||||
trace: list[str],
|
||||
) -> None:
|
||||
deltas = {
|
||||
key: abs(float(current[key]) - float(baseline[key]))
|
||||
for key in ("x", "y", "width", "height")
|
||||
}
|
||||
shifted = {k: v for k, v in deltas.items() if v > EPSILON}
|
||||
if shifted:
|
||||
raise cmuxError(
|
||||
"Container frame shifted "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sample={sample}, "
|
||||
f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON}); "
|
||||
f"recent_actions={trace}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_two_panes_left_right(layout_payload: dict, *, workspace_index: int, cycle: int, trace: list[str]) -> None:
|
||||
panes = _pane_frames_sorted_x(layout_payload)
|
||||
if len(panes) != 2:
|
||||
raise cmuxError(
|
||||
f"Expected exactly 2 panes in two-pane phase, got {len(panes)} "
|
||||
f"(workspace={workspace_index}, cycle={cycle}); panes={panes}; recent_actions={trace}"
|
||||
)
|
||||
|
||||
left, right = panes[0], panes[1]
|
||||
if left["width"] <= 0.0 or left["height"] <= 0.0 or right["width"] <= 0.0 or right["height"] <= 0.0:
|
||||
raise cmuxError(
|
||||
f"Collapsed pane in two-pane phase (workspace={workspace_index}, cycle={cycle}): "
|
||||
f"left={left} right={right}; recent_actions={trace}"
|
||||
)
|
||||
|
||||
if left["x"] >= right["x"]:
|
||||
raise cmuxError(
|
||||
f"Two-pane geometry is not left/right (workspace={workspace_index}, cycle={cycle}): "
|
||||
f"left={left} right={right}; recent_actions={trace}"
|
||||
)
|
||||
|
||||
|
||||
def _selected_panel_by_pane(layout_payload: dict) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for row in layout_payload.get("selectedPanels") or []:
|
||||
pane_id = str(row.get("paneId") or "")
|
||||
panel_id = str(row.get("panelId") or "")
|
||||
if pane_id and panel_id:
|
||||
out[pane_id] = panel_id
|
||||
return out
|
||||
|
||||
|
||||
def _rightmost_pane_id(layout_payload: dict) -> str:
|
||||
panes = _pane_frames_sorted_x(layout_payload)
|
||||
if len(panes) < 2:
|
||||
raise cmuxError(f"Expected at least 2 panes to resolve rightmost pane: {panes}")
|
||||
pane_id = str(panes[-1].get("pane_id") or "")
|
||||
if not pane_id:
|
||||
raise cmuxError(f"Rightmost pane is missing pane_id: {panes[-1]}")
|
||||
return pane_id
|
||||
|
||||
|
||||
def _rightmost_panel_id(layout_payload: dict) -> str:
|
||||
pane_id = _rightmost_pane_id(layout_payload)
|
||||
selected = _selected_panel_by_pane(layout_payload)
|
||||
panel_id = str(selected.get(pane_id) or "")
|
||||
if not panel_id:
|
||||
raise cmuxError(f"Missing selected panel for rightmost pane: pane_id={pane_id}, selected={selected}")
|
||||
return panel_id
|
||||
|
||||
|
||||
def _safe_layout_debug(c: cmux, *, timeout_state: dict[str, int], context: str) -> dict:
|
||||
for attempt in range(0, max(0, LAYOUT_TIMEOUT_RETRIES) + 1):
|
||||
try:
|
||||
return c.layout_debug()
|
||||
except cmuxError as exc:
|
||||
if "timed out waiting for response" not in str(exc).lower():
|
||||
raise
|
||||
|
||||
timeout_state["count"] = timeout_state.get("count", 0) + 1
|
||||
count = timeout_state["count"]
|
||||
if count > max(0, MAX_LAYOUT_TIMEOUTS):
|
||||
raise cmuxError(
|
||||
f"Exceeded layout_debug timeout budget (count={count}, max={MAX_LAYOUT_TIMEOUTS}, context={context})"
|
||||
) from exc
|
||||
|
||||
if attempt >= max(0, LAYOUT_TIMEOUT_RETRIES):
|
||||
raise cmuxError(
|
||||
f"layout_debug timed out after retries (attempts={attempt + 1}, count={count}, context={context})"
|
||||
) from exc
|
||||
|
||||
if LAYOUT_TIMEOUT_RETRY_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_TIMEOUT_RETRY_SLEEP_S)
|
||||
|
||||
raise cmuxError(f"layout_debug retry loop exhausted unexpectedly (context={context})")
|
||||
|
||||
|
||||
def _sample_while(
|
||||
c: cmux,
|
||||
*,
|
||||
baseline: dict,
|
||||
deadline: float,
|
||||
workspace_index: int,
|
||||
cycle: int,
|
||||
phase: str,
|
||||
trace: list[str],
|
||||
timeout_state: dict[str, int],
|
||||
) -> int:
|
||||
sampled = 0
|
||||
while time.time() < deadline:
|
||||
payload = _safe_layout_debug(
|
||||
c,
|
||||
timeout_state=timeout_state,
|
||||
context=f"sample workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
|
||||
)
|
||||
current = _container_frame(payload)
|
||||
_assert_same_frame(
|
||||
current=current,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase=phase,
|
||||
sample=sampled,
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
panes_now = _pane_count(payload)
|
||||
if panes_now > 2:
|
||||
raise cmuxError(
|
||||
f"Observed >2 panes in strict two-pane fuzz "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
|
||||
f"recent_actions={trace}"
|
||||
)
|
||||
sampled += 1
|
||||
if LAYOUT_POLL_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_POLL_SLEEP_S)
|
||||
return sampled
|
||||
|
||||
|
||||
def _wait_for_panes(
|
||||
c: cmux,
|
||||
*,
|
||||
target_panes: int,
|
||||
baseline: dict,
|
||||
workspace_index: int,
|
||||
cycle: int,
|
||||
phase: str,
|
||||
timeout_s: float,
|
||||
trace: list[str],
|
||||
timeout_state: dict[str, int],
|
||||
) -> tuple[dict, int]:
|
||||
deadline = time.time() + timeout_s
|
||||
sampled = 0
|
||||
last = None
|
||||
|
||||
while time.time() < deadline:
|
||||
payload = _safe_layout_debug(
|
||||
c,
|
||||
timeout_state=timeout_state,
|
||||
context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
|
||||
)
|
||||
last = payload
|
||||
current = _container_frame(payload)
|
||||
_assert_same_frame(
|
||||
current=current,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase=phase,
|
||||
sample=sampled,
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
panes_now = _pane_count(payload)
|
||||
if panes_now > 2:
|
||||
raise cmuxError(
|
||||
f"Observed >2 panes in strict two-pane fuzz while waiting "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
|
||||
f"recent_actions={trace}"
|
||||
)
|
||||
if panes_now == target_panes:
|
||||
return payload, sampled + 1
|
||||
sampled += 1
|
||||
if LAYOUT_POLL_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_POLL_SLEEP_S)
|
||||
|
||||
raise cmuxError(
|
||||
f"Timed out waiting for {target_panes} panes "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, "
|
||||
f"last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); recent_actions={trace}"
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_single_pane_after_ctrl_d(
|
||||
c: cmux,
|
||||
*,
|
||||
baseline: dict,
|
||||
workspace_index: int,
|
||||
cycle: int,
|
||||
phase: str,
|
||||
timeout_s: float,
|
||||
recent_actions: deque[str],
|
||||
timeout_state: dict[str, int],
|
||||
) -> tuple[dict, int, int]:
|
||||
deadline = time.time() + timeout_s
|
||||
sampled = 0
|
||||
extra_ctrl_d = 0
|
||||
last = None
|
||||
next_retry_at = time.time() + max(0.0, CTRL_D_RETRY_INTERVAL_S)
|
||||
|
||||
while time.time() < deadline:
|
||||
payload = _safe_layout_debug(
|
||||
c,
|
||||
timeout_state=timeout_state,
|
||||
context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
|
||||
)
|
||||
last = payload
|
||||
current = _container_frame(payload)
|
||||
trace = list(recent_actions)
|
||||
_assert_same_frame(
|
||||
current=current,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase=phase,
|
||||
sample=sampled,
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
panes_now = _pane_count(payload)
|
||||
if panes_now > 2:
|
||||
raise cmuxError(
|
||||
f"Observed >2 panes in strict two-pane fuzz while waiting "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
|
||||
f"recent_actions={trace}"
|
||||
)
|
||||
if panes_now == 1:
|
||||
return payload, sampled + 1, extra_ctrl_d
|
||||
|
||||
now = time.time()
|
||||
if panes_now == 2 and extra_ctrl_d < max(0, CTRL_D_MAX_EXTRA) and now >= next_retry_at:
|
||||
retry_right_panel_id = _rightmost_panel_id(payload)
|
||||
try:
|
||||
c.send_key_surface(retry_right_panel_id, "ctrl-d")
|
||||
except cmuxError as exc:
|
||||
# Pane/surface can disappear between layout sample and send call under heavy churn.
|
||||
# Skip this retry tick and re-sample.
|
||||
if "not_found" in str(exc).lower():
|
||||
next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S)
|
||||
sampled += 1
|
||||
if LAYOUT_POLL_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_POLL_SLEEP_S)
|
||||
continue
|
||||
raise
|
||||
extra_ctrl_d += 1
|
||||
recent_actions.append(
|
||||
f"ws={workspace_index} cycle={cycle} action=ctrl+d(extra:{extra_ctrl_d}/{CTRL_D_MAX_EXTRA},surface={retry_right_panel_id})"
|
||||
)
|
||||
next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S)
|
||||
|
||||
sampled += 1
|
||||
if LAYOUT_POLL_SLEEP_S > 0:
|
||||
time.sleep(LAYOUT_POLL_SLEEP_S)
|
||||
|
||||
raise cmuxError(
|
||||
f"Timed out waiting for 1 pane after ctrl+d "
|
||||
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, "
|
||||
f"extra_ctrl_d={extra_ctrl_d}, last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); "
|
||||
f"recent_actions={list(recent_actions)}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rng = random.Random(FUZZ_SEED)
|
||||
recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL))
|
||||
total_samples = 0
|
||||
total_cycles = 0
|
||||
total_extra_ctrl_d = 0
|
||||
timeout_state: dict[str, int] = {"count": 0}
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
c.activate_app()
|
||||
|
||||
for workspace_index in range(1, WORKSPACES + 1):
|
||||
ws = c.new_workspace()
|
||||
c.select_workspace(ws)
|
||||
c.activate_app()
|
||||
time.sleep(0.08)
|
||||
|
||||
start = _safe_layout_debug(c, timeout_state=timeout_state, context=f"workspace={workspace_index} start")
|
||||
baseline = _container_frame(start)
|
||||
start_panes = _pane_count(start)
|
||||
if start_panes != 1:
|
||||
raise cmuxError(f"New workspace did not start as single pane (workspace={workspace_index}, panes={start_panes})")
|
||||
|
||||
for cycle in range(1, CYCLES_PER_WORKSPACE + 1):
|
||||
total_cycles += 1
|
||||
|
||||
if PRE_ACTION_JITTER_MAX_S > 0:
|
||||
time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S))
|
||||
|
||||
recent_actions.append(f"ws={workspace_index} cycle={cycle} action=cmd+d")
|
||||
c.simulate_shortcut("cmd+d")
|
||||
|
||||
after_split, sampled = _wait_for_panes(
|
||||
c,
|
||||
target_panes=2,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase="after_cmd+d",
|
||||
timeout_s=TRANSITION_TIMEOUT_S,
|
||||
trace=list(recent_actions),
|
||||
timeout_state=timeout_state,
|
||||
)
|
||||
total_samples += sampled
|
||||
_assert_two_panes_left_right(after_split, workspace_index=workspace_index, cycle=cycle, trace=list(recent_actions))
|
||||
|
||||
hold_split = rng.uniform(HOLD_MIN_S, HOLD_MAX_S)
|
||||
total_samples += _sample_while(
|
||||
c,
|
||||
baseline=baseline,
|
||||
deadline=time.time() + hold_split,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase="hold_2pane",
|
||||
trace=list(recent_actions),
|
||||
timeout_state=timeout_state,
|
||||
)
|
||||
|
||||
if PRE_ACTION_JITTER_MAX_S > 0:
|
||||
time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S))
|
||||
|
||||
right_panel_id = _rightmost_panel_id(after_split)
|
||||
recent_actions.append(f"ws={workspace_index} cycle={cycle} action=ctrl+d(surface={right_panel_id})")
|
||||
c.send_key_surface(right_panel_id, "ctrl-d")
|
||||
|
||||
_, sampled, extra_ctrl_d = _wait_for_single_pane_after_ctrl_d(
|
||||
c,
|
||||
baseline=baseline,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase="after_ctrl+d",
|
||||
timeout_s=TRANSITION_TIMEOUT_S,
|
||||
recent_actions=recent_actions,
|
||||
timeout_state=timeout_state,
|
||||
)
|
||||
total_samples += sampled
|
||||
total_extra_ctrl_d += extra_ctrl_d
|
||||
|
||||
hold_single = rng.uniform(HOLD_MIN_S, HOLD_MAX_S)
|
||||
total_samples += _sample_while(
|
||||
c,
|
||||
baseline=baseline,
|
||||
deadline=time.time() + hold_single,
|
||||
workspace_index=workspace_index,
|
||||
cycle=cycle,
|
||||
phase="hold_1pane",
|
||||
trace=list(recent_actions),
|
||||
timeout_state=timeout_state,
|
||||
)
|
||||
|
||||
c.close_workspace(ws)
|
||||
time.sleep(0.05)
|
||||
|
||||
print(
|
||||
"PASS: strict two-pane cmd+d/ctrl+d frame guard "
|
||||
f"(seed={FUZZ_SEED}, workspaces={WORKSPACES}, cycles={total_cycles}, samples={total_samples}, "
|
||||
f"extra_ctrl_d={total_extra_ctrl_d}, epsilon={EPSILON}, layout_timeouts={timeout_state.get('count', 0)})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
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